Strings
Strings are sequences of Unicode characters
Section titled “Strings are sequences of Unicode characters”Python 3 strings (str) are immutable sequences of Unicode code points.
Single quotes and double quotes are interchangeable — choose one and be consistent.
Triple quotes (""" or ''') allow multi-line strings.
a = "Hello"b = 'World'c = """This spansmultiple lines."""Common string methods
Section titled “Common string methods”Strings ship with dozens of methods. These are the ones you will use constantly:
s = " Hello, Python! "print(s.strip()) # "Hello, Python!"print(s.upper()) # " HELLO, PYTHON! "print(s.lower()) # " hello, python! "print(s.replace("Python", "World")) # " Hello, World! "print(s.startswith(" H")) # Trueprint(s.endswith("! ")) # Trueprint(len(s)) # 18Methods return new strings — they never modify the original.
Slicing
Section titled “Slicing”Strings support the same slicing syntax as lists: s[start:stop:step].
Negative indices count from the end.
s = "Hello, Python!"print(s[0:5]) # Helloprint(s[7:]) # Python!print(s[-7:]) # Python!print(s[::-1]) # !nohtyP ,olleH (reversed)print(s[::2]) # Hlo yhnbytes vs str
Section titled “bytes vs str”str is text — an immutable sequence of Unicode code points, with no inherent byte size. bytes is raw data — an immutable sequence of integers 0–255. Everything crossing a network socket, a file opened in binary mode, or most encryption/hashing APIs is bytes, not str. .encode() turns str into bytes using a named encoding; .decode() turns bytes back into str, and raises UnicodeDecodeError if the bytes are not valid for that encoding:
s = "café"b = s.encode("utf-8")print(b) # b'caf\xc3\xa9'print(len(s), len(b)) # 4 5 -- "é" is one character but two UTF-8 bytesprint(b.decode("utf-8")) # café
b.decode("ascii") # UnicodeDecodeError: 'ascii' codec can't decode byte 0xc3 ...len(s) counts characters; len(b) counts bytes — they only agree when every character is ASCII. Decoding with the wrong encoding is a common source of crashes when data crosses a boundary (a file, a network response, a subprocess) without an explicit, matching encoding= on both ends.
f-strings (formatted string literals)
Section titled “f-strings (formatted string literals)”f-strings, introduced in Python 3.6, are the standard way to embed expressions in strings.
Prefix the string with f and place any Python expression inside {}.
name = "Alice"age = 30pi = 3.14159
print(f"Name: {name}, Age: {age}")print(f"Pi to 2dp: {pi:.2f}")print(f"2 + 2 = {2 + 2}")print(f"Upper: {name.upper()}")Format specifiers after : control alignment, precision, and type — the same mini-language used by str.format().
t-strings (template strings, Python 3.14)
Section titled “t-strings (template strings, Python 3.14)”An f-string is evaluated immediately and hands you a finished str. That is exactly the problem when the
result goes into SQL, a shell command, or HTML: by the time your code sees the string, the interpolated
values are indistinguishable from the literal text around them.
A t-string (PEP 750, Python 3.14) uses the t prefix and evaluates to a Template instead of a str.
The literal chunks and the substituted values stay separate, so a library can escape the values before
assembling the final text.
variety = "Stilton"template = t"Try some {variety} cheese!"
print(template.strings) # ('Try some ', ' cheese!')print(template.interpolations[0].value) # Stiltonprint(template.interpolations[0].expression) # varietyIterating a Template yields the literal parts as str and the substitutions as Interpolation objects —
which is what makes safe rendering possible:
from string.templatelib import Interpolation, Template
variety = "Stilton"template = t"Try some {variety} cheese!"
print(type(template).__name__)print(template.strings)
for part in template: if isinstance(part, Interpolation): print("value:", part.value, "| expression:", part.expression) else: print("literal:", repr(part))
def shout(tpl: Template) -> str: out = [] for part in tpl: out.append(str(part.value).upper() if isinstance(part, Interpolation) else part) return "".join(out)
print(shout(t"hello {variety}, and {1 + 1} more"))Loading Python runtime (first run only)…
Choosing str.format, an f-string, or a t-string
Section titled “Choosing str.format, an f-string, or a t-string”All three share the same {}/format-spec mini-language, but they answer different questions:
| Form | Evaluates to | When |
|---|---|---|
str.format(...) | str, immediately | The template string is data — read from a config file, a translation table, or user-supplied at runtime, so it cannot be an f-string literal in your source |
| f-string | str, immediately | You want finished text right now, in your own source code, and nothing downstream needs to tell values apart from literal text |
| t-string | Template, lazily | The result is headed somewhere that must escape or validate values separately from literal text — SQL, HTML, a shell command, a logging framework that redacts fields |
Reach for an f-string when you want text now. Reach for a t-string when something downstream has to know
which parts came from your program and which came from your data. Reach for str.format only when the
template itself is not a literal in your source.
join() and split()
Section titled “join() and split()”str.split(sep) breaks a string into a list.
sep.join(iterable) joins a list of strings into one.
They are inverses of each other.
csv = "one,two,three,four"parts = csv.split(",") # ['one', 'two', 'three', 'four']rejoined = " | ".join(parts) # 'one | two | three | four'print(parts)print(rejoined)String concatenation in a loop is quadratic
Section titled “String concatenation in a loop is quadratic”str is immutable, so result += "x" does not extend result in place — it builds an entirely new string object, copying every existing character plus the new one, then rebinds result to it. Doing that n times copies roughly 1 + 2 + ... + n characters in total: O(n²). "".join(pieces) computes the total length once and copies each character exactly once: O(n).
import time
n = 20000start = time.perf_counter()result = ""for i in range(n): result += "x"concat_time = time.perf_counter() - start
start = time.perf_counter()joined = "".join("x" for _ in range(n))join_time = time.perf_counter() - start
print(result == joined) # Trueprint(concat_time > join_time) # True (machine-dependent, but the trend holds)Always join with a string method (", ".join(items)), never concatenate in a loop — loop concatenation creates O(n²) intermediate strings.
The strip() cutset trap
Section titled “The strip() cutset trap”s.strip(chars) does not remove a literal prefix/suffix substring — chars is treated as a set of individual characters, each stripped from both ends for as long as it keeps matching:
domain = "www.example.com"print(domain.strip("www.")) # "example.com" -- looks correct... but only by luck
messy = "www.wow.com"print(messy.strip("www.")) # "ow.com" -- also eats the "w" that starts "wow"print(messy.removeprefix("www.")) # "wow.com" -- compares a literal substring insteadstrip("www.") builds the cutset {'w', '.'} — any run of w or . characters at either end gets removed, regardless of order or how many there are. It happened to give the “right” answer for domain only because none of example.com’s edge characters are w or .. The moment the real content starts with one of those characters ("wow.com"), strip eats into it. Use removeprefix() / removesuffix() (Python 3.9+) when you mean “remove this exact substring,” and reserve strip() for “trim any of these characters” (like whitespace).
repr() vs str()
Section titled “repr() vs str()”str(obj) produces a human-readable form; repr(obj) produces an unambiguous, developer-facing form — ideally one that could be pasted back into Python to recreate the object. print() uses str(); the REPL and containers (printing a list of strings) use repr():
text = "line1\nline2"print(str(text)) # line1 # line2 (an actual newline)print(repr(text)) # 'line1\nline2' (the escape sequence, visible, on one line)This is why print(["a\n", "b"]) shows ['a\n', 'b'] with the escape visible, even though print("a\n") alone shows a real line break — a list’s str() falls back to calling repr() on each element, precisely so escape characters and quote boundaries stay visible instead of scrambling the output.
Full runnable demo
Section titled “Full runnable demo”s = "Hello, Python!"print(s.upper())print(s.lower())print(s.replace("Python", "World"))print(s.startswith("Hello"))print(len(s))
print(s[0:5])print(s[-7:])print(s[::2])
name = "Alice"age = 30pi = 3.14159print(f"Name: {name}, Age: {age}")print(f"Pi to 2dp: {pi:.2f}")
words = ["one", "two", "three"]joined = ", ".join(words)print(joined)parts = "a:b:c:d".split(":")print(parts)
try: s[0] = "h"except TypeError as e: print(f"TypeError: {e}")Loading Python runtime (first run only)…
The second playground is a tour of the pitfalls above: encoding/decoding, the strip() cutset trap, repr vs str, and the quadratic cost of looped concatenation.
import time
# 1. bytes vs str: encoding, decoding, and the errors that cross the boundary.s = "café"b = s.encode("utf-8")print("encoded:", b)print("len(str) vs len(bytes):", len(s), len(b))print("decoded back:", b.decode("utf-8"))try: b.decode("ascii")except UnicodeDecodeError as e: print(f"UnicodeDecodeError: {e}")
# 2. The strip() cutset trap: the argument is a SET of characters, not a substring.domain = "www.example.com"print(domain.strip("www.")) # "example.com" -- looks correct, but only by luck
messy = "www.wow.com"print(messy.strip("www.")) # over-strips: eats into "wow" tooprint(messy.removeprefix("www.")) # removeprefix compares a literal substring instead
# 3. repr() vs str().text = "line1\nline2"print(str(text)) # the actual two linesprint(repr(text)) # one line, escape sequence visible
# 4. += in a loop is O(n^2); "".join is O(n).n = 20000start = time.perf_counter()result = ""for i in range(n): result += "x"concat_time = time.perf_counter() - start
start = time.perf_counter()joined = "".join("x" for _ in range(n))join_time = time.perf_counter() - start
print("same content:", result == joined)print("concatenation was slower than join:", concat_time > join_time)Loading Python runtime (first run only)…
Mental Model
Section titled “Mental Model”A str is not a mutable buffer of characters — every method call, every concatenation, and every slice produces a fresh, independent str object. Two names can point at the same string safely (unlike a list), because nothing can change it out from under either name. bytes follows the same immutable-value model, one layer lower — before Unicode decoding has happened.
flowchart LR
str1["str: 'café'"] -->|".encode('utf-8')"| bytesObj["bytes: b'caf\xc3\xa9'"]
bytesObj -->|".decode('utf-8')"| str2["str: 'café' (new object)"]
str1 -->|".upper()"| str3["str: 'CAFÉ' (new object)"] Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
Immutable str | Safe to share across names/threads; usable as a dict key | Every transformation allocates a new string — including one built one character at a time |
bytes/str split | No ambiguity about whether data is decoded text or raw bytes | Every I/O boundary needs an explicit encoding=, and a wrong guess raises UnicodeDecodeError |
| f-strings | Fast, readable, evaluated where you write them | The template itself can’t be external data (a config value, a translation) |
| t-strings | Values stay inspectable and escapable before assembly | One more concept to learn; nothing consumes a bare Template without library support |
strip()’s cutset semantics | One method handles “trim any of these characters,” useful for whitespace | Easy to mistake for literal-prefix/suffix removal, which is a different operation (removeprefix/removesuffix) |
Common Misconceptions
Section titled “Common Misconceptions”- “
len(a_string)andlen(a_string.encode())are always the same.” Only for pure-ASCII text — any multi-byte character makes the byte length larger than the character count. - “
s.strip("www.")removes the literal prefix"www.".” It removes any run of the characterswand.from both ends — a character set, not a substring. Useremoveprefix/removesuffixfor literal removal. - “
str(x)andrepr(x)always print the same thing.” They agree for simple numbers, but diverge for strings (quotes and escapes appear inrepr) and for custom objects, where they can be defined completely differently. - “Building a string with
+=in a loop is just as fast as''.join().” It is not —stris immutable, so each+=copies the whole string so far into a new object, making a loop ofnconcatenations O(n²) instead of O(n). - “An f-string and a t-string do the same thing with a different prefix letter.” An f-string is a
str, finished the instant it’s written; a t-string is aTemplateobject, still holding literal and interpolated parts separately for something downstream to process.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does the code build a string with
+=inside a loop, especially one that could run many iterations, instead of collecting pieces in a list and calling"".join(...)once? - Does
.strip(...),.lstrip(...), or.rstrip(...)receive a multi-character argument where the intent looks like “remove this prefix/suffix,” rather than “remove any of these characters”? That should probably beremoveprefix/removesuffix. - Does the code mix
strandbytesin one expression (for example concatenating astrand abytesvalue), or call.encode()/.decode()without an explicitencoding=and then assume it always succeeds? - Does a log message or debug print use
str()(or an f-string) on a value where the escape-visible, unambiguous form ofrepr()would actually be more useful for diagnosing a bug? - Is a template string built by hand with
%or.format()when the string doesn’t come from outside the source file — could it just be an f-string, or does it need to be a t-string because the result is headed somewhere with an injection risk?
🐛 Spot the bug
def build_report(rows): text = "" for row in rows: text += f"{row}\n" return textShow the answer
This function looks correct — it does produce the right string — but for large rows it is quietly O(n²): str is immutable, so every text += ... copies the entire string built so far into a brand-new object just to add one more line. With a thousand rows that is roughly half a million characters copied instead of one thousand. The fix collects the pieces in a list and joins once: return "\n".join(str(row) for row in rows) + "\n", which copies each character exactly once regardless of how many rows there are.