Strings and Text
Index and slice strings correctly, understand why they're immutable, format text with f-strings, and tell characters apart from the bytes that represent them.
By the end of this lesson, you can
- Index and slice a string correctly, including negative indices and exclusive end bounds
- Explain why string methods never mutate their target and predict the resulting object either way
- Format text with f-strings, including format specs like precision and alignment
- Distinguish a str's length in characters from its length in encoded bytes
Why it matters
Text is the first data almost every program touches — a filename, a user’s input, a line from a file. Getting indexing, slicing, and formatting right early avoids off-by-one bugs that otherwise resurface in every string-handling lesson from here on, and knowing where “characters” stop and “bytes” start heads off a whole class of encoding bugs before they happen.
Mental model
A str is an immutable sequence of Unicode code points — immutable meaning no method ever
changes it in place; sequence meaning it supports indexing, slicing, iteration, and len(), the
same shape list and tuple share.
| Expression | Result | Why |
|---|---|---|
| word[0] | "p" | Index 0 is the first character. |
| word[-1] | "n" | Negative indices count from the end; -1 is the last character. |
| word[1:4] | "yth" | A slice includes the start index and excludes the stop index — 1, 2, 3. |
| word[:2] | "py" | An omitted start defaults to 0. |
| word[4:] | "on" | An omitted stop defaults to the end of the string. |
Worked example: building a slug, safely
def slugify(title: str) -> str:
words = title.strip().lower().split()
cleaned = ["".join(ch for ch in word if ch.isalnum()) for word in words]
return "-".join(word for word in cleaned if word)
def main() -> None:
slug = slugify(" Learn Python, Deliberately! ")
print(f"Slug: {slug}")
word = "café"
print(f"'{word}' has {len(word)} characters but {len(word.encode('utf-8'))} UTF-8 bytes")
if __name__ == "__main__":
main()Slug: learn-python-deliberately
'café' has 4 characters but 5 UTF-8 bytesslugify leans entirely on string methods — .strip(), .lower(), .split(), and a filtered
join — rather than punctuation-matching, which is exactly the kind of task plain string methods
handle cleanly. (Regular expressions exist for messier parsing problems, but reaching for one here
would be solving a harder problem than this one actually is.)
The second line is the str-versus-bytes distinction made concrete: café is 4 characters — c,
a, f, é are each one code point — but é alone takes 2 bytes once encoded as UTF-8, so the encoded
form is 5 bytes. len() on a str always counts characters (code points); it never tells you
how many bytes that text will take up once written somewhere.
Common mistakes
- Treating
len(text)as “how many bytes is this” for any text that might contain accented letters, emoji, or other non-ASCII characters — encode first, then measure thebytesresult if byte size is actually what you need (file sizes, network payloads, storage limits). - Off-by-one slicing: forgetting that
word[1:4]stops before index 4, not at it. - Writing
word.strip()on its own line and expectingwordto have changed afterward — the return value has to be used or reassigned.
Check your understanding
Or reveal the answer without checking
Answer:tho
Index 2 is "t", 3 is "h", 4 is "o" — the slice includes indices 2, 3, and 4 and excludes index 5, giving "tho".
Or reveal the answer without checking
Answer:abc
s.upper() returns a new string without modifying s — the return value was captured in result, not assigned back to s, so s still prints as the original 'abc'.
Practice: warm-up
Predict len("naïve") before checking it. Is the answer 5 or 6? (The ï is a single precomposed
code point here, not a base letter plus a separate combining mark — but you can’t tell that just by
looking at it, which is the actual point of this exercise.)
Stuck? Reveal one hint at a time.
Hint 1
len() on a str counts code points, not bytes and not necessarily "letters you can see" in every possible case.
Hint 2
If "ï" here is one precomposed code point (as it is when typed normally on most systems), it counts as exactly one unit toward len(), same as any other letter.
Hint 3
len("naïve") is 5 — n, a, ï, v, e — five code points, five characters, regardless of how many bytes "ï" takes once encoded.
Reveal the answer
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
len("naïve") -> 5Five code points: n, a, ï, v, e. The lesson worth taking away isn’t the specific number —
it’s that len() on a str is always a code-point count, and that’s a different question from “how
many bytes will this take up,” which only len(text.encode(...)) answers.
Practice: apply it
Write a small text-statistics function, text_stats(text: str) -> tuple[int, int], returning
(word_count, char_count) — split on whitespace for words, and use len() for characters. Predict
its output for "the quick brown fox" before running it.
Or reveal the answer without checking
Answer:4
"the quick brown fox".split() splits on whitespace by default and produces 4 words: "the", "quick", "brown", "fox".
Modification challenge: make text_stats also return the length of the longest word, and
predict what changes if the input string has extra spaces between words (hint: .split() with no
arguments already handles that case — check the reference for str if
you’re not sure why).
Summary
stris an immutable sequence of Unicode code points — slicing, indexing, andlen()all work the same way they do on other sequences, but no method ever changes a string in place.- Slices include the start index and exclude the stop index; negative indices count from the end.
- f-strings evaluate their embedded expressions immediately and support a format-spec mini-language for precision, alignment, and more — see f-strings.
len(s)counts characters (code points), never bytes — encode explicitly (s.encode("utf-8")) whenever a byte count is what you actually need.- Plain string methods (
.strip(),.split(),.lower(), …) solve most everyday text tasks; regular expressions are a separate, more powerful tool worth reaching for only once a problem genuinely needs pattern matching, not before.