Strings and Bytes

Updated

September 8, 2026

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.py

Output:

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.py

Output:

[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.py

Output:

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.py

Output:

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.py

Output (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

  • str in memory. bytes on the wire. UTF-8 at the door.
  • Assign the result of replace, strip, upper, encode.
  • split to parse. join to render. The joiner is the separator.
  • f-strings for messages. !r in logs.
  • Never + a str and bytes. Never join ints.

Try this

  1. In rewrite_label.py, strip a label with spaces (" table 3 ") and assign it back.
  2. Extend parse_tables to reject a token that is not all digits ("3, eleven") with ValueError.
  3. In ticket_encode.py, encode with "utf-16" and print len(raw) versus len(text).