skip to content

Why do str and bytes never substitute for each other in annotations?

level: middleimportance: should knowfreq 34%

answer

  1. The lenient rule is numeric only
  2. No shared base class either way
  3. A conversion would need a choice
  4. Encoding is the programmer's decision
  5. Decode once at the boundary

basics

~20 s

There is no promotion between text and binary data: str and bytes share no subclass relation, and PEP 484's implicit widening is numeric-only. A checker therefore demands an explicit .encode() or .decode(), because no single correct encoding can be assumed.

solid answer

~40 s

The implicit promotion people meet first — an `int` accepted where `float` is annotated — is written into PEP 484 for the three numeric types **only**. `str` and `bytes` are unrelated classes with no inheritance link, and Python 3 removed every implicit coercion between them: `b"42" == "42"` is `False`, and `b"42" + "42"` raises `TypeError`. A checker cannot insert a conversion because it would have to invent an encoding, and the wrong guess corrupts data silently rather than loudly. So the boundary conversion has to be written by hand: `.decode("utf-8")` coming in, `.encode("utf-8")` going out, once, at the edge where the bytes are read. Beware `str(payload)` as a shortcut — with no encoding argument it produces the repr `"b'42'"` rather than decoding, which is one of the classic silent corruptions.

code

python · 7 lines
python
frame = b"  42  "
print(int(frame))
try:
    frame.split(",")
except TypeError as exc:
    print("text separator on a bytes value:", exc)
print(frame.decode("ascii").split(","))

go deeper

for a junior

Remember that text and binary data are separate types with no automatic conversion, and that turning one into the other means calling encode or decode with an encoding you name yourself.

for a middle

Explain that PEP 484's implicit widening covers only int, float and complex, that str and bytes share no base class, and that a conversion would require choosing an encoding the checker cannot pick.

for a senior

Show the debugging angle: operations such as int() accept bytes, so mix-ups survive into production on untested branches. Describe decoding once at the I/O boundary and keeping the interior text-only.

for a principal

Own the boundary policy — where in the system bytes become text, which encoding is canonical, how dual-mode helpers are typed with overloads or constrained type parameters, and what the codebase does about legacy data.

## The rule, and its deliberate limit PEP 484's implicit promotion is often summarised as "the type system is lenient about closely related types". It is not. It is lenient about **exactly three** types — `int`, `float`, `complex` — and about nothing else. `str` and `bytes` are the pair people most expect to be next in line, and they are permanently excluded. Statically, `str` and `bytes` are siblings: neither inherits from the other, and both merely implement `collections.abc.Sequence`-shaped behaviour over different element types. A `str` is a sequence of Unicode code points; a `bytes` is a sequence of integers in `range(256)`. Indexing them proves the difference — `"abc"[0]` is `"a"`, while `b"abc"[0]` is `97`. ## Why no coercion is possible A promotion rule needs a *canonical* conversion. `int` → `float` has one: the same number, in a wider representation. Text to bytes has none — it needs an **encoding**, and the choice is not the type system's to make. UTF-8 is the sane default in 2026, but the same byte string decodes to entirely different text under a legacy single-byte codec, and a wrong guess produces a `UnicodeDecodeError` at best and mojibake at worst. Python 3's entire text model rests on making that choice explicit and doing it once, at the I/O boundary. The runtime enforces the same line: ```pycon >>> b"42" == "42" False >>> b"42" + "42" TypeError: can't concat str to bytes >>> "a,b".split(b",") TypeError ``` Note that the equality comparison does **not** raise; it quietly returns `False`. Running the interpreter with the `-b` command-line flag turns that comparison into a `BytesWarning`, and `-bb` makes it an error — a useful setting for a test run when a codebase is being audited for text/bytes confusion. ## The dangerous middle ground What makes this class of bug expensive is that *some* operations accept both, so the mistake does not fail at the first opportunity. In a sensor-telemetry collector reading frames off a socket, the payloads arrive as `bytes`, and a parsing helper is annotated `str`: ```python frame = b" 42 " int(frame) # 42 - int() happily accepts a bytes value frame.strip() # b'42' - works, but returns bytes frame.split(",") # TypeError: a bytes-like object is required, not 'str' ``` `int()` accepting bytes is the one that catches people: the numeric field parses fine, the code appears to work, and the failure only surfaces on the branch that splits a delimited field or formats a message. When that branch is the clock-skew correction path exercised by one device family, the defect can ride a three-week release train unnoticed. The annotation was the warning; the checker was reporting a real error on the call site all along. ## Modelling APIs that genuinely take both Some functions honestly accept either — think of a codec-style helper, or a path handler. Options, in order of preference: * **Overloads.** Declare one signature per input type so the return type follows the argument: given `str` you return `str`, given `bytes` you return `bytes`. This is how the standard library's own stubs model such functions. * **A constrained type parameter.** With PEP 695 syntax (Python 3.12+) you can write `def clean[S: (str, bytes)](value: S) -> S: ...`, which says the function works for either but never mixes them in one call. `typing.AnyStr` was the old spelling of exactly this and is **deprecated since Python 3.13**. * **A union, `str | bytes`.** Simplest, but it loses the connection between argument and return type, so callers get a union back and must narrow it. What you should *not* do is annotate `str` and let bytes arrive because "it works", nor sprinkle `str(value)` to silence the checker. Without an encoding argument `str(b"42")` returns the four-character repr `"b'42'"` — the checker is satisfied, the program runs, and the data is now wrong. The correct spelling is `value.decode("utf-8")` or `str(value, "utf-8")`. ## The interview answer Say that the promotion is numeric-only by design; that `str` and `bytes` have no subclass relationship and no implicit conversion since Python 3; that a conversion would require choosing an encoding, which is the programmer's decision; and that the discipline is to decode once at the boundary and keep the interior pure text. Mentioning that `int(b"42")` works — so the runtime is not a reliable guard — shows you have debugged this rather than only read about it.

  • How do you annotate a helper that legitimately accepts either str or bytes and returns the same kind?
    Use overloads, or a constrained type parameter: `def clean[S: (str, bytes)](value: S) -> S: ...` with PEP 695 syntax on Python 3.12+. Both preserve the link between argument and return type, so a `bytes` in yields a `bytes` out. `typing.AnyStr` was the older spelling of the same idea and is deprecated since 3.13. A plain `str | bytes` union works but forces every caller to narrow the result.
  • Why is str(payload) a bad way to satisfy a str annotation?
    With no encoding argument, `str()` falls back to the object's repr, so `str(b"42")` is the four-character text `"b'42'"`, complete with the prefix and quotes. The checker is silenced and the program keeps running with corrupted data — the worst combination. Decode explicitly with `payload.decode("utf-8")`, or pass the encoding as `str(payload, "utf-8")`, so a malformed byte sequence raises rather than being smuggled through.
  • Since the runtime does not raise on b'42' == '42', how can you catch these mix-ups in testing?
    Run the interpreter with the `-b` command-line flag, which turns comparing `bytes` with `str` into a `BytesWarning`, or `-bb`, which makes it an error. Combined with a type checker over the same code, that covers both the annotated call sites and the dynamic paths where a value arrived from parsing or deserialization. The structural fix remains decoding once at the I/O boundary so the interior handles text only.

Widening an int to a float is like reading a measurement on a finer ruler; turning bytes into text is like translating between languages, and a translator must be named before the sentence means anything.

saying these in an interview costs you the question

  • Expecting a str-to-bytes promotion like int to float
  • Believing b'42' == '42' evaluates to True
  • Using str(payload) to convert bytes to text
  • Assuming ASCII data makes the two interchangeable
  • Thinking the runtime always raises on mixing them
  • Decoding repeatedly through a codebase instead of at the boundary

context