Strings and Bytes
Strings and Bytes
A str is immutable Unicode text. bytes is immutable raw 8-bit data. The boring default is text inside the program, bytes at the edge (files, sockets, subprocesses), and encode / decode with "utf-8" when you cross that edge.
Mental model
You cannot change a character in a string in place. label.replace("12", "3") returns a new string. The name label still points at the old one unless you assign the result back.
split turns a string into a list of strings. join goes the other way: ", ".join(parts). The joiner is the string you call join on. split with no argument splits on any whitespace and drops empties. split(",") keeps empties ("3,,12" → ['3', '', '12']).
f-strings are expressions in braces. They call str() on the value unless you add a conversion (!r for repr). Format specs go after a colon: {table:>4}, {cents / 100:.2f}.
encode is str → bytes. decode is bytes → str. Both take an encoding and an error mode ("strict" default, "replace", "ignore"). Mismatched encodings produce mojibake or UnicodeDecodeError.
Worked examples
Case 1: Immutable text, assign the result
Save as rewrite_label.py.
# rewrite_label.py
def main():
label = "table 12"
label.replace("12", "3")
print("after replace, no assign:", label)
label = label.replace("12", "3")
print("after assign:", label)
print(label.upper())
print("table" in label)
if __name__ == "__main__":
main()Run:
uv run python rewrite_label.pyOutput:
after replace, no assign: table 12
after assign: table 3
TABLE 3
True
Forgetting to assign replace / upper / strip is the daily string bug. Methods return new objects.
Case 2: split and join
# split_tables.py
def parse_tables(raw):
parts = raw.split(",")
return [int(p.strip()) for p in parts if p.strip()]
def main():
tables = parse_tables("3, 11, 12")
print(tables)
print(", ".join(str(t) for t in tables))
print("3,,12".split(","))
print(" 3 11 12 ".split())
if __name__ == "__main__":
main()Run:
uv run python split_tables.pyOutput:
[3, 11, 12]
3, 11, 12
['3', '', '12']
['3', '11', '12']
join needs strings, so str(t) on each int. An empty field from split(",") is why if p.strip() is there. int("") would raise ValueError.
Case 3: f-strings at the desk
# ticket_fstring.py
def main():
ticket = {"id": 7, "table": 12, "cents": 1850, "note": "window"}
print(f"ticket {ticket['id']} → table {ticket['table']}")
print(f"${ticket['cents'] / 100:.2f}")
print(f"note={ticket['note']!r}")
width = 4
print(f"table {ticket['table']:>{width}}")
if __name__ == "__main__":
main()Run:
uv run python ticket_fstring.pyOutput:
ticket 7 → table 12
$18.50
note='window'
table 12
!r is what you want in logs so spaces and empty strings stay visible. Nested quotes: use the other quote around the f-string, or index with a name first (tid = ticket["id"]).
Case 4: Encode at the edge
# ticket_encode.py
def main():
text = "café 12"
raw = text.encode("utf-8")
print(raw)
print(raw.decode("utf-8"))
try:
raw.decode("ascii")
except UnicodeDecodeError as e:
print(type(e).__name__)
print(text.encode("ascii", errors="replace"))
if __name__ == "__main__":
main()Run:
uv run python ticket_encode.pyOutput:
b'caf\xc3\xa9 12'
café 12
UnicodeDecodeError
b'caf? 12'
UTF-8 round-trips. ASCII cannot hold é, so "strict" raises and "replace" writes ?. Do not use "ignore" for data you care about — it drops bytes with no trace.
The trap
Treating bytes as a string, or building a path / JSON body as bytes by accident.
# join_bytes.py
def main():
tables = [3, 11]
line = ",".join(tables)
if __name__ == "__main__":
main()Run:
uv run python join_bytes.pyOutput (the process exits non-zero):
Traceback (most recent call last):
File "join_bytes.py", line 8, in <module>
main()
File "join_bytes.py", line 4, in main
line = ",".join(tables)
TypeError: sequence item 0: expected str instance, int found
join does not stringify for you. The bytes twin is b",".join([b"3", b"11"]) — still bytes, still not mixed with str. Convert first: ",".join(str(t) for t in tables).
The boring rule
strin memory.byteson the wire. UTF-8 at the door.- Assign the result of
replace,strip,upper,encode. splitto parse.jointo render. The joiner is the separator.- f-strings for messages.
!rin logs. - Never
+astrandbytes. Neverjoinints.
Try this
- In
rewrite_label.py,stripa label with spaces (" table 3 ") and assign it back. - Extend
parse_tablesto reject a token that is not all digits ("3, eleven") withValueError. - In
ticket_encode.py, encode with"utf-16"and printlen(raw)versuslen(text).