Text Is Not a Single Thing
A string looks like one piece of text until you need its third character. Slice a sentence apart by position and find out why the end index is never the one you first guess.
A string looks like one piece of text until you need the third character of it. Underneath, Python treats it as a sequence — the same idea it uses for a list — and that changes what “the third character” actually means.
A string is a sequence, not a word
Index a string with square brackets, the same way you would a list, and counting starts at 0, not 1.
$ >>> name = "python"$ >>> name[0]$ 'p'$ >>> name[5]$ 'n'name[0] is the first character precisely because Python counts positions, not places in line — position 0 is where you start counting from, and it happens to hold the first character.
Negative indices count backwards from the end, so you never need to know a string's length just to reach its last character.
$ >>> name[-1]$ 'n'$ >>> name[-6]$ 'p'Two of the ordinary arithmetic operators work on strings too, because a string is a sequence and sequences can be joined and repeated. + concatenates; * repeats.
$ >>> "py" + "thon"$ 'python'$ >>> "ab" * 3$ 'ababab'Slicing without the off-by-one
A slice, text[start:end], pulls out a whole range at once. The part everyone gets wrong at least once: end is not included.
>>> text[4:8]
'with'
The character at index 8 is not included — a slice runs up to, but not through, its end index. That is why a slice's length is always end - start, here 4.
f-strings are the modern default
Building a message out of a string and a variable used to mean concatenating pieces by hand, watching the types and the spacing yourself. An f-string does the whole job in place.
$ >>> name = "Ada"$ >>> age = 28$ >>> f"{name} is {age} years old"$ 'Ada is 28 years old'The f before the opening quote is what turns the braces from literal characters into holes Python fills in. Anything that evaluates to a value can go inside them — not just a variable name, but a full expression.
$ >>> price = 19.5$ >>> f"Total: {price * 2:.2f}"$ 'Total: 39.00'The part after the colon, .2f, is a format spec — “fixed-point, two decimal places”. It runs on the value after the expression is evaluated, which is why it can turn a plain 39.0 into the two-decimal 39.00 a price actually needs to display.
A handful of methods worth memorising
A string comes with dozens of built-in methods. Most of what you write day to day leans on a small handful of them.
Removes whitespace from both ends — the first thing to reach for on anything typed by a person.
Breaks a string into a list, on whitespace by default, or on whatever separator you pass it.
The reverse of split — glues a list of strings back together with the string you call it on, in between each piece.
Returns a lowercase copy, useful for comparing text without caring how it was capitalised.
Returns a copy with every match of one substring swapped for another.
$ >>> " Ada Lovelace ".strip()$ 'Ada Lovelace'$ >>> "Ada Lovelace".split()$ ['Ada', 'Lovelace']$ >>> "-".join(["Ada", "Lovelace"])$ 'Ada-Lovelace'Notice the shape all five share: every one of them returns a new value instead of announcing what it did. Nothing prints unless you print it, and nothing changes unless you keep the result — which is exactly the next section.
Nothing about a string ever changes in place
Try to change one character of a string directly, and Python refuses outright.
$ >>> name = "python"$ >>> name[0] = "P"$ TypeError: 'str' object does not support item assignmentStrings are immutable — once created, a string never changes. Every method that looks like it edits one, such as .upper() or .replace(), actually builds and returns a brand new string, leaving the original exactly as it was.
$ >>> name = "python"$ >>> name.upper()$ 'PYTHON'$ >>> name$ 'python'Immutability is not an arbitrary restriction. It is what lets Python use a string safely as a dictionary key, or hash it at all — a value that could silently change size or content out from under you would break every dictionary relying on it staying exactly as it was the moment it was stored.
The encoding issue you will eventually hit
Everything so far has treated a string as a sequence of characters. Underneath, a file on disk is not characters — it is bytes, and something has to agree on how those bytes map back to text. That agreement is an encoding, and getting it wrong is one of the most common ways a beginner's program crashes on someone else's machine but not their own.
$ >>> "café".encode("utf-8")$ b'caf\xc3\xa9'$ >>> "café".encode("utf-8").decode("ascii")$ UnicodeDecodeError: 'ascii' codec can't decode byte 0xc3 in position 3: ordinal not in range(128).encode() turns text into bytes under a given encoding; .decode() turns bytes back into text, and it has to be told the same encoding the bytes were written in, or it guesses wrong. é is one character but two bytes in UTF-8 — ascii, an older encoding that only covers the first 128 characters, has no representation for either of them.
Key takeaways
- A string is indexed like any sequence, counting starts at 0, and negative indices count backwards from the end.
- A slice text[start:end] never includes the character at end — its length is always end minus start.
- An f-string fills {expression} holes directly in the text, and a format spec like :.2f controls how the value is displayed.
- Strings are immutable. Every method that looks like it edits one, such as .strip() or .replace(), returns a new string instead.
- A file is bytes, not text. Pass encoding="utf-8" explicitly when opening one, or the default can differ by operating system.
Quick check
Answer these to unlock the next chapter — 3 of 4 to pass. You can retake it anytime.
Answer every question to check.
Make a free account to read on
Every chapter is free — an account is how your progress, XP, and streak follow you from your laptop to your phone, and how you show up on the leaderboard. No payment, no trial.