skip to content

Why does `case str(s)` bind the whole string instead of an attribute in Python?

level: middleimportance: nice to knowfreq 18%

answer

  1. A hard-coded list of builtin types
  2. One slot, matched against the subject
  3. No attribute is read at all
  4. A type test and a bind in one
  5. object is not on the list

basics

~20 s

str is one of a dozen builtin types the match statement special-cases: a single positional sub-pattern matches the whole subject rather than an attribute, so case str(s) means 'is a str, and bind it to s'.

solid answer

~40 s

Most classes map positional sub-patterns onto `__match_args__` attributes, but `bool`, `bytearray`, `bytes`, `dict`, `float`, `frozenset`, `int`, `list`, `set`, `str` and `tuple` are special-cased by the language: a single positional sub-pattern is matched against the whole subject. That makes `case str(s)` a typed capture — an isinstance test and a binding in one — which is exactly what you want when a value could be a string or something else. These types accept at most one positional sub-pattern; `case str(a, b)` raises `TypeError`. The rule does not generalise: `object` is not in the list, so `case object(x)` raises `TypeError` about accepting zero positional sub-patterns. Note that `bool` subclasses `int`, so `True` matches `case int(n)`.

code

python · 16 lines
python
def describe(value):
    match value:
        case str(text):
            return f"str of length {len(text)}"
        case int(number):
            return f"int {number}"
        case list(items):
            return f"list of {len(items)}"
        case _:
            return "something else"


print(describe("rebuild"))
print(describe(1200))
print(describe(["docs", "terms"]))
print(describe({"shard": 3}))

go deeper

for a junior

It is enough to recognise the shape: case str(s) means the value is a string and gets bound to s. Nobody will mark you down for not knowing the full list of special-cased builtins.

for a middle

Explain that a fixed set of builtins matches a single positional sub-pattern against the whole subject, and that the rule does not extend to your own classes or to object.

for a senior

Show the practical judgement: use builtin class patterns to narrow heterogeneous input with a real type gate, and remember the subclass traps — bool under int, and container patterns that bind rather than unpack.

for a principal

Frame it as a readability call. A typed-capture chain over builtins is clear for a handful of leaf types, but once branches multiply it is a type dispatch in disguise and usually belongs behind a parsing or validation boundary.

Structural pattern matching had a spelling problem to solve. The overwhelmingly common thing you want to say about a builtin value is "if this is a string, call it `text`" — a type test and a binding together. Every ordinary class pattern spends its positional slots on attributes, but a string has no interesting attribute to destructure; the string *is* the value. So the language special-cases a fixed list of builtin types. ## The list and the rule The special-cased types are `bool`, `bytearray`, `bytes`, `dict`, `float`, `frozenset`, `int`, `list`, `set`, `str` and `tuple`. For each of them, a class pattern with **exactly one positional sub-pattern** matches that sub-pattern against the **whole subject** rather than against an attribute. `case str(text)` therefore means: check `isinstance(subject, str)`, and if it holds, match the capture pattern `text` against the subject itself, binding the string. The sub-pattern does not have to be a capture. `case int(0)` is a type test plus a literal comparison — it matches only an integer equal to zero, which is stricter than the bare literal `case 0` (that would also match `0.0` and `False`, since literal patterns compare with `==`). `case list([])` combines the type gate with a sequence pattern. Only one positional sub-pattern is allowed. `case str(a, b)` raises `TypeError` saying `str()` accepts 1 positional sub-pattern and 2 were given — the shape of the message is the same arity error every class pattern produces, just with a limit of one. And these classes do not gain a general destructuring ability: keyword sub-patterns still read real attributes, so `case str(encode=x)` would look up the method object, which is legal and useless. ## Why it does not generalise Nothing in the mechanism is available to your own classes. There is no dunder that opts a type into self-matching, and `object` — the obvious candidate for "match anything and bind it" — is not on the list: `case object(x)` raises `TypeError` about accepting zero positional sub-patterns. The list is a hard-coded set of builtins, chosen because they are the leaf types of most data. If you want the same effect for a class of your own, the language already gives it to you another way: match the class with no sub-patterns and capture the whole subject with an as-pattern. ## Where it earns its place The classic use is narrowing a heterogeneous value. Parsed configuration, decoded JSON-shaped data, an argument that a permissive API accepts in several forms: the value could be a string, a number, or a container, and each branch wants both the type check and the value. Written as class patterns the chain reads as a typed switch, and every branch is guarded by a real isinstance rather than by a bare name that would match anything. The gotcha to keep in mind is subclassing. `bool` is a subclass of `int`, so `True` matches `case int(n)` and binds `True` — put a `bool` case above the `int` case if the distinction matters. Similarly, any subclass of `str` matches `case str(s)`, which is usually what you want but occasionally is not. The second gotcha is the container types. `case list(items)` binds the whole list; it does not iterate or unpack anything. To look inside, you either nest a sequence pattern inside it — `case list([first, *rest])` — or use a bare sequence pattern, which accepts more types than `list` alone. `case dict(d)` behaves the same way: whole-subject binding, not a key lookup. ## Interview framing This is a differentiator, not a gate. Nobody's offer turns on knowing the list of eleven types by heart. What a strong candidate demonstrates is the reasoning: they see `case str(s)`, recognise that a string has no attribute worth destructuring there, and infer that the positional slot must mean something different for builtins. Being able to name three or four of the types and state the one-sub-pattern limit is more than enough; being able to say why the rule exists — that a type-test-plus-bind is the single most common thing you want from a builtin in a pattern — is the answer that lands.

  • How many positional sub-patterns may `case dict(d)` take?
    One. The special case allows a single positional sub-pattern matched against the whole subject; two raise `TypeError` saying the class accepts one positional sub-pattern and reporting how many were given. If you want to look inside the dictionary, nest a mapping pattern in that one slot rather than adding more slots.
  • Does `case object(x)` behave the same way?
    No. `object` is not in the special-cased list and has no `__match_args__`, so it accepts zero positional sub-patterns and raises `TypeError`. The self-matching rule is a hard-coded set of builtins, not something a class can opt into. To match any subject and keep it, use a plain capture or an as-pattern instead.
  • What does `case int(n)` match when the subject is `True`?
    It matches and binds `True`, because `bool` is a subclass of `int` and the class pattern gate is an isinstance test. If booleans need separate handling, put `case bool(b)` above the `int` case. The same subclass logic applies to any `str` or `list` subclass reaching a builtin class pattern.

For a string there is no pocket to search, so the pattern holds up the whole value instead: case str(s) says 'this is the thing itself, label it s'.

saying these in an interview costs you the question

  • Thinks `case str(s)` reads an attribute named s
  • Believes any class can self-match one sub-pattern
  • Expects `case str(a, b)` to split the string
  • Claims `str` defines a `__match_args__` tuple
  • Forgets that True matches `case int(n)`
  • Thinks `case list(items)` unpacks the elements

context