2025, Nov 24 13:00
When Python's structural pattern matching breaks with str subclasses: understanding equality, class patterns, and __match_args__
Learn why positional class patterns on str subclasses fail in Python 3.10+ pattern matching, how __match_args__ works, and fixes for equality edge cases.
Structural pattern matching in Python 3.10+ is powerful, but it has one subtle edge case that bites when you subclass str and override equality. If you rely on BLOB-like string wrappers to preserve parsing semantics — for example, ensuring that wrapped strings don’t compare equal to plain str — a class pattern like case MyType("text") won’t behave the way you expect. Here’s what’s really going on and what trade-offs you’ll have to make.
Reproducing the issue
The following snippet subclasses str, customizes equality to only accept comparisons with the same class, and then attempts to use match/case with a positional class pattern. The logic is minimal but representative.
class Flux(str):
def __eq__(self, rhs):
return self.__class__ == rhs.__class__ and str.__eq__(self, rhs)
def __ne__(self, rhs):
return not self == rhs
# Guarantees needed by the design
assert Flux('a') == Flux('a')
assert Flux('a') != 'a'
# Now try structural pattern matching
hit = False
match Flux("asdf"):
case Flux("asdf"):
hit = True
assert hit
This looks like it should match, but it doesn’t. A quick diagnostic print of the participating classes inside the equality method shows that the right-hand side ends up being a plain str, not an instance of the subclass, which leads to a failed comparison under this equality policy.
What class patterns actually do
Class patterns match by attributes. When you write case SomeType(attr="value"), the engine conceptually validates that the subject is an instance of SomeType and then compares the attribute by name. If you rely on positional subpatterns like case SomeType("value"), Python needs a mapping from positional indices to attribute names, which is provided by the class-level __match_args__.
Think of the following transformation as the mental model. With a named attribute pattern:
match obj:
case Gadget(kind="sensor"):
...
the match behaves like an isinstance check plus an attribute comparison:
isinstance(obj, Gadget) and obj.kind == "sensor"
With positional matching, __match_args__ maps positions to attribute names, so case Gadget("sensor") is analogous to:
isinstance(obj, Gadget) and getattr(obj, Gadget.__match_args__[0]) == "sensor"
To make that work, the class exposes attributes along with __match_args__:
from typing import ClassVar
class Gizmo:
__match_args__: ClassVar[tuple[str, ...]] = ("kind",)
def __init__(self, kind: str):
self.kind = kind
The special case for str and its subclasses
There’s an important exception documented for a set of built-in types. For these, a single positional subpattern is accepted that matches against the entire object, not a named attribute. One of those special-cased types is str. Because a subclass of str inherits that behavior, a pattern like case Sub("asdf") becomes an isinstance check against the subclass followed by an equality comparison to the plain string literal.
In other words, this conceptual rewrite applies:
match obj:
case Flux("asdf"):
...
turns into something equivalent to:
isinstance(obj, Flux) and obj == "asdf"
That explains the failure. The overridden equality in the subclass explicitly refuses to treat the subclass instance as equal to a plain str, so the pattern doesn’t match.
What the constraints imply
You can’t have all of the following at once: the class is a subclass of str, the subclass instance does not compare equal to a raw str like Flux("foo") != "foo", and a positional pattern case Flux("bar") that matches Flux("bar"). The special handling for str forces the positional class pattern to use equality against the full string, and the custom equality rejects comparisons to plain str, so the two requirements are in direct conflict.
So what actually works
If your goal is attribute-based pattern matching with positional syntax, model the object with attributes and define __match_args__. That’s the contract Python expects class patterns to follow when there isn’t a built-in special case involved. Here is a minimal example that aligns with how class patterns are designed to operate:
from typing import ClassVar
class Widget:
__match_args__: ClassVar[tuple[str, ...]] = ("label",)
def __init__(self, label: str):
self.label = label
ok = False
match Widget("asdf"):
case Widget("asdf"):
ok = True
assert ok
This works because the positional subpattern maps to the label attribute via __match_args__, and the engine performs an attribute comparison rather than the special whole-object equality used for str.
Why this matters
Structural pattern matching is not a syntactic alias for calling __eq__. Class patterns are fundamentally attribute-driven, except where Python carves out specific behaviors for certain built-ins. Subclassing str pulls your type into that special path, where a positional subpattern is interpreted as an equality check against the entire string. If your equality semantics are deliberately asymmetric with plain str, the match will fail even if the string contents look identical.
Takeaways
When you design types for pattern matching, be explicit about which invariant you prefer. If you need a strict separation from plain str so Flux("x") != "x" stays true, then a positional class pattern case Flux("x") won’t be compatible. If you want the positional class pattern to succeed, you must allow equality with raw str or avoid subclassing str and instead expose a real attribute with __match_args__. Understanding the distinction between attribute-based patterns and the special full-object comparison for str helps avoid unexpected mismatches and keeps your pattern matching code predictable.