Modules/Python/ Text Is a Sequence

Strings and Slicing

A string is a sequence of characters with a position. Indexing grabs one position; slicing grabs a run of them — the single most used trick in text work.

Overview

Immutable, and why that matters

A string cannot be modified in place. s[0] = "H" raises a TypeError, and every method that appears to change a string — upper, replace, strip — returns a new string and leaves the original untouched.

s = "hello"
s.upper()   # -> "HELLO"
print(s)   # -> "hello"  unchanged

Forgetting to assign the result is the most common string bug there is. The performance consequence matters too: building a string by repeated concatenation in a loop copies everything each time, which is quadratic. Collect the pieces in a list and "".join(parts) at the end.

Indexing from the start

Positions start at 0. s[0] is the first character; negative numbers count back from the end.

Python 3
Output

                            

Slicing: start : stop : step

The stop position is excluded — the slice ends just before it.

Python 3
Output

                            

Where every index points

p0
y1
t2
h3
o4
n5
s[1:4] grabs yth — positions 1, 2 and 3, stopping before 4. Leaving a side blank means "as far as it goes": s[:3] starts at 0, s[3:] runs to the end.
The negative side is free. s[-1] is the last character and s[::-1] is the whole string backwards — the classic one-liner palindrome test.

Strings and Slicing: A Practical Guide

Strings are immutable sequences of characters. Slicing pulls out any part of one, and immutability means every operation that looks like a change is really building a new string.

Slicing

Slices take [start:stop:step], with start inclusive, stop exclusive, and any part omittable. With s = "Python":

s[0]   # -> "P"
s[-1]   # -> "n"    negative counts from the end
s[0:3]   # -> "Pyt"
s[2:]   # -> "thon"
s[:3]   # -> "Pyt"
s[::2]   # -> "Pto"
s[::-1]   # -> "nohtyP" reversed

The exclusive stop makes s[:k] + s[k:] reconstruct the original for any k, and the length of s[a:b] is simply b − a. Slices also never raise an IndexError — "abc"[10:20] returns an empty string rather than crashing, unlike indexing a single position.

The methods worth knowing

text = "  Hello, World  "

text.strip()                 # 'Hello, World'  - also lstrip, rstrip
text.lower()                 # case conversion - also .upper(), .title()
text.replace("World", "Ada") # a new string with the substitution
text.split(",")              # ['  Hello', ' World  ']
text.strip().startswith("H") # True   - also endswith
"World" in text              # True   - substring test
text.find("World")           # index, or -1 if absent
text.count("l")              # 3
"a,b,c".split(",")           # ['a', 'b', 'c']
", ".join(["a", "b"])        # 'a, b'

Two habits that prevent real bugs. Always .strip() user input and data read from files — trailing whitespace and newlines are the single most common cause of "but they look identical" comparison failures. And normalise case before comparing: a.lower() == b.lower(), or a.casefold() == b.casefold() for correct handling of non-English text.

f-strings, and formatting numbers

f-strings are the modern way to build text, and they take expressions, not just names:

name, total = "Ada", 1234.5678

f"Hello, {name}"                  # 'Hello, Ada'
f"Total: {total:.2f}"             # 'Total: 1234.57'
f"Total: {total:,.2f}"            # 'Total: 1,234.57'
f"{0.847:.1%}"                    # '84.7%'
f"{42:>8}"                        # '      42'   right-aligned in 8 columns
f"{name=}"                        # "name='Ada'"  -- excellent for debugging

That last one, the = suffix, prints both the expression and its value, and it removes most of the reason to write print("name:", name).

Multi-line strings use triple quotes and keep their line breaks, which makes them right for SQL, templates and docstrings.

Exploration guide

  1. Confirm immutability. Call .upper() without assigning, then print the original. Unchanged — the result was discarded.
  2. Reverse with a slice. Run s[::-1] and check the original is untouched. Every slice is a new string.
  3. Slice past the end. Try s[10:20] on a short string. Empty result, no error — then try s[10] and get IndexError.
  4. Split and rejoin. Split on a comma, then join with a different separator, and watch the round trip.

Traps worth knowing

  • Not assigning the result. s.replace("a", "b") alone does nothing observable.
  • Concatenating in a loop. Quadratic. Use a list and join.
  • Calling join on the list. It is a method of the separator string.
  • Confusing find and index. find returns −1 when absent; index raises.
  • Assuming one character is one byte. Python strings are Unicode, so a character may be several bytes when encoded — len() counts characters, not bytes.

The short version

Strings are immutable sequences, so every transforming method returns a new string and the original never changes — assign the result, and never build strings by concatenation in a loop. Slicing uses an exclusive stop, accepts negative indices, and returns an empty string rather than raising when the range is out of bounds. split and join are the two methods most text processing is actually made of.

Escapes, raw strings and encodings

Some characters need escaping inside a string:

"line one\nline two"      # \n is a newline
"tab\there"               # \t is a tab
"she said \"hi\""         # escaped quotes
'she said "hi"'           # or just use the other quote character
r"C:\Users\new"           # raw string: backslashes are literal

Raw strings matter most for Windows paths and regular expressions, where \n in the pattern should mean backslash-n rather than a newline.

Underneath, Python 3 strings are Unicode, so they hold any character from any writing system. Files and networks carry bytes, so text has to be encoded on the way out and decoded on the way in:

data = "café".encode("utf-8")     # b'caf\xc3\xa9'  -- bytes
text = data.decode("utf-8")       # 'café'          -- back to str

Always specify encoding="utf-8" when opening a text file. The default depends on the operating system, which is why a script that works on one machine produces UnicodeDecodeError on another.

One more subtlety: len("café") is 4 characters, but the UTF-8 encoding is 5 bytes. Characters and bytes are different units, and mixing them up is the source of most truncated-text bugs.

Common mistakes

  • Expecting a method to modify the string. text.upper() returns a new one; assign it.
  • Building strings with += in a loop. Use "".join(parts).
  • Comparing unstripped input. "yes\n" == "yes" is False.
  • Using + to join a number to text. "Total: " + 5 raises TypeError; use an f-string.
  • Testing the result of find for truth. It returns -1 when the substring is absent, which is truthy, and 0 when the match is at the very start, which is falsy — so if text.find(x): is exactly backwards in both cases. Use in for a yes-or-no test, and compare against -1 when you want the position.
  • Ignoring encodings until a file with an accented character arrives.

Why immutability is a feature

"Cannot be changed" reads as a restriction, and it buys three things the language relies on.

Strings can be dictionary keys. A key's hash decides where its entry is stored, so a key whose contents could change would end up filed under a hash that no longer matches it. Every dictionary you index by name depends on strings being immutable, which is the same rule that stops a list from being a key.

Strings can be shared without copying. Passing a string to a function costs nothing regardless of length, because nothing is duplicated and no caller has to worry that the callee will edit it underneath them. Python takes advantage of this internally: identical short strings in your source are often the same object.

Strings are safe across threads. Nothing can observe a half-modified string, because there is no modification to observe.

The cost is one specific case: building a string piece by piece. Each += copies everything accumulated so far, so a loop over n pieces does work proportional to n squared. That is the entire reason join exists, and it is worth reaching for by reflex rather than after measuring — "".join(parts) is no harder to write than the loop it replaces.

Everywhere else, the guarantee that a string you were handed is the string you still have is worth considerably more than the ability to edit one in place.

Characters, bytes, and the length that surprises people

A Python string holds Unicode code points, and a file or a network holds bytes. The two are different units, and treating them as the same is behind most text-handling bugs.

print(len("café"), len("café".encode("utf-8")))
4 5

Four characters, five bytes — the accented e needs two. That gap is why truncating text to "100 characters" and truncating to "100 bytes" are different operations, and why a field limit expressed in bytes can reject a string that looks well within it.

Three habits follow. Decode as early as possible and encode as late as possible, so the middle of your program works in str and never in bytes. Name the encoding explicitly on every file you open, because the default varies by platform and that is precisely how a script that works on one machine raises UnicodeDecodeError on another. And when comparing text that came from different sources, normalise it first — the same visible character can be one code point or two, and unicodedata.normalize("NFC", text) collapses them so that strings which look identical compare equal.

For English-only data none of this ever surfaces, which is exactly why it is worth knowing before the first name with an accent in it arrives.

Slicing does not raise, and what that hides

Indexing asserts that a position exists; slicing does not.

print(repr("abc"[10:20]))
''

An out-of-range slice returns whatever exists, which is often nothing, and never raises. That is genuinely convenient — taking "the first ten characters" of something that might be shorter needs no length check — and it is a place bugs hide, because an empty result looks like legitimate data rather than a mistake.

The practical rule is to choose based on whether absence is a bug. If a position must exist, index it and let IndexError say so at the line responsible. If fewer characters than asked for is an ordinary outcome, slice and let it clamp.

When a slice with computed bounds comes back empty, do not assume the input was empty. Print the bounds. An off-by-one that produces s[5:5] is indistinguishable in the output from a string that genuinely had nothing to give, and the two have completely different causes.

split and join, which most text work is made of

Two methods do the bulk of real string handling, and they are inverses of each other.

split breaks text into a list. With no argument it splits on any run of whitespace and discards the empties, which is what you want for text typed by a person — two spaces between words does not produce a phantom entry. With an argument it splits on exactly that separator, every time it occurs, and does produce empty strings between adjacent separators. That difference is the one to remember: "a b".split() gives two items, "a b".split(" ") gives three.

maxsplit limits how many splits happen, counting from the left, which is how you take the first field and leave the rest intact: line.split(":", 1) gives exactly two pieces however many colons follow. rsplit does the same from the right, for taking a file extension or a last segment.

partition is the version that never needs a length check: it splits once and always returns three pieces, so key, sep, value = line.partition("=") works whether or not the separator was there. When it was absent, sep and value are empty and the unpacking still succeeds.

join goes the other way and is called on the separator, not on the list, which reads backwards until you have written it a few times. Think of it as "put this between them". It requires strings, so a list of numbers needs converting first: ", ".join(str(n) for n in nums).

Questions people ask

How do I reverse a string? text[::-1].

How do I check if a string contains something? "abc" in text.

What is the difference between split() and split(" ")? Bare split() splits on any run of whitespace and drops empties; split(" ") splits on each single space and can produce empty strings.

Is str.format() obsolete? Not obsolete, but f-strings are clearer for almost every case. format is still useful when the template comes from elsewhere, such as a config file.

How do I pad a number with zeros? f"{n:03d}" gives '007'.

Why does "1" + 1 fail? Because Python is strongly typed and refuses to guess whether you meant "11" or 2. Convert explicitly.

How do I remove a suffix? removesuffix(".csv"). Not strip(".csv"), which removes any of those characters from both ends and will eat a trailing s or v.

Why does "a" * 3 work? Multiplying a sequence repeats it, so you get "aaa". The same works on lists, which is where the shared-row trap comes from.

Is there a character type? No. A single character is just a string of length one, which is why indexing a string gives another string.

Why is s[0] a string and not a character? Python has no character type; indexing a sequence of strings gives a string of length one.

Can I sort the characters of a string? "".join(sorted(s))sorted gives a list, which join turns back into text.

How do I repeat a string? "-" * 40 gives a divider line, which is the usual use.

Does strip() remove characters from the middle? No, only from the two ends. Use replace for the middle.

What is the difference between str and repr? str is for people and repr for programmers — print uses the first, and containers and the prompt use the second, which is why lists show quoted strings.

Recap in one screen

  • Strings are immutable; every "modifying" method returns a new string.
  • Slicing works exactly as with lists, and the stop index is excluded.
  • Build text with "".join(parts), not repeated +=.
  • Strip and case-normalise before comparing anything that came from a human or a file.
  • f-strings handle formatting, alignment and percentages, and f"{x=}" is a debugging shortcut.
  • Text is Unicode; files are bytes. Say encoding="utf-8" explicitly.

Check yourself

0 of 3

Answer without scrolling back up.

  1. len("hello") returns:

  2. s = "python"; s[0] is:

  3. "hello".upper() returns:

Cheat sheet

Strings and Slicing

A string is a sequence of characters with a position. Indexing grabs one position; slicing grabs a run of them — the single most used trick in text work.

PYTHON · vizlearn.in/python/strings_and_slicing.html

Further reading

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.