Skip to content

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 spans
multiple lines."""

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")) # True
print(s.endswith("! ")) # True
print(len(s)) # 18

Methods return new strings — they never modify the original.

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]) # Hello
print(s[7:]) # Python!
print(s[-7:]) # Python!
print(s[::-1]) # !nohtyP ,olleH (reversed)
print(s[::2]) # Hlo yhn

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 bytes
print(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, 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 = 30
pi = 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().

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) # Stilton
print(template.interpolations[0].expression) # variety

Iterating 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"))

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:

FormEvaluates toWhen
str.format(...)str, immediatelyThe 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-stringstr, immediatelyYou want finished text right now, in your own source code, and nothing downstream needs to tell values apart from literal text
t-stringTemplate, lazilyThe 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.

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 = 20000
start = 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) # True
print(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.

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 instead

strip("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).

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.

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 = 30
pi = 3.14159
print(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}")

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" too
print(messy.removeprefix("www.")) # removeprefix compares a literal substring instead
# 3. repr() vs str().
text = "line1\nline2"
print(str(text)) # the actual two lines
print(repr(text)) # one line, escape sequence visible
# 4. += in a loop is O(n^2); "".join is O(n).
n = 20000
start = 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)

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)"]
Every string operation returns a new object; str and bytes convert via an explicit encoding
What you getBenefitCost
Immutable strSafe to share across names/threads; usable as a dict keyEvery transformation allocates a new string — including one built one character at a time
bytes/str splitNo ambiguity about whether data is decoded text or raw bytesEvery I/O boundary needs an explicit encoding=, and a wrong guess raises UnicodeDecodeError
f-stringsFast, readable, evaluated where you write themThe template itself can’t be external data (a config value, a translation)
t-stringsValues stay inspectable and escapable before assemblyOne more concept to learn; nothing consumes a bare Template without library support
strip()’s cutset semanticsOne method handles “trim any of these characters,” useful for whitespaceEasy to mistake for literal-prefix/suffix removal, which is a different operation (removeprefix/removesuffix)
  • “len(a_string) and len(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 characters w and . from both ends — a character set, not a substring. Use removeprefix/removesuffix for literal removal.
  • “str(x) and repr(x) always print the same thing.” They agree for simple numbers, but diverge for strings (quotes and escapes appear in repr) 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 — str is immutable, so each += copies the whole string so far into a new object, making a loop of n concatenations 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 a Template object, still holding literal and interpolated parts separately for something downstream to process.
  • 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 be removeprefix/removesuffix.
  • Does the code mix str and bytes in one expression (for example concatenating a str and a bytes value), or call .encode()/.decode() without an explicit encoding= 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 of repr() 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 text
Show 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.

What does this print?
s = "café"
b = s.encode("utf-8")
print(len(s), len(b))
What does this print?
messy = "www.wow.com"
print(messy.strip("www."))
What is the idiomatic way to remove exactly the literal prefix "www." from a string, without risking the strip() cutset behavior?
What does this print?
print(str("a\nb"))
print(repr("a\nb"))
Why is building a large string with result += piece inside a loop considered O(n^2)?
What does a t-string (Python 3.14) evaluate to?