Skip to content

← Data Modelling and Invariants step 19 of 25

Medium Primitives

NamedTuple: when a record should be a tuple

A cache keyed on (user_id, tenant_id) collides with an (x, y) coordinate, and no test finds it. That is not a hypothetical: it is the direct consequence of the one property that makes NamedTuple different from every other record type in Python.

Cross-type equality is the whole trade

class Point(NamedTuple):
    x: int
    y: int

class Date(NamedTuple):
    x: int
    y: int

Point(1, 2) == Date(1, 2)     # True
Point(1, 2) == (1, 2)         # True

A dataclass returns False for both, because its generated __eq__ starts with other.__class__ is self.__class__. A NamedTuple inherits tuple.__eq__, which compares contents only. There is no class check anywhere in the chain.

That is not a bug — it is the contract you asked for. A NamedTuple is a tuple: it unpacks, it slices, it concatenates, it goes straight into % formatting and zip, and it compares equal to any tuple with the same elements. When the value genuinely is a pair or a row — a coordinate, an RGB triple, a DB row, a divmod result — that compatibility is the point, and the type is the right one.

When the value is a domain entity — an Order, a User, a CacheKey — it is the wrong one, for exactly the same reason.

The rest of the trade-offs

  • Attribute access is slower, not faster: ~8.3 ns through the _tuplegetter descriptor vs ~3.8 ns for a slotted dataclass. It is a tuple index plus a descriptor call.
  • Equality is much faster: ~9.9 ns vs ~45.8 ns for a dataclass, because it is one C-level tuple comparison instead of a Python-level field walk.
  • Immutable, hashable, ordered for free. _replace() is the replace() equivalent; _asdict() returns a plain dict without asdict‘s deep-copy tax.
  • ⚠ 3.14 breaking change: super() (zero-arg) or __class__ inside a method of a NamedTuple subclass now raises TypeError. If you have such a method, it worked on 3.13 and does not on 3.14.

The strict-mode reason to prefer typing.NamedTuple

collections.namedtuple("Point", "x y") produces a class whose attributes are typed Any. p.x + "oops" type-checks. --strict reports nothing — the only flag that catches it is the non-strict --disallow-any-expr. typing.NamedTuple types every field correctly. So the choice between the two spellings is not style: one of them silently punches an Any-shaped hole in your module.


Your task

Complete Interval(NamedTuple) with lo: int and hi: int:

  • overlaps(other) — true when the two intervals share at least one point, including touching at an endpoint ([1,2] overlaps [2,5]);
  • merge(other) — the smallest interval containing both;
  • __contains__(point)point in interval is true for an int inside the closed range, and false (never an exception) for anything else. Note that this overrides tuple.__contains__, which would otherwise test membership among the elements.

Then same_kind(a, b) — true only when the two tuples are the same concrete class — and merge_all(intervals) returning the coalesced, ascending list.

solve(intervals, probe) returns:

  • "merged" — the merged intervals, as Interval objects (they serialise as tuples; returning lists fails);
  • "contains"probe in interval for each merged interval;
  • "tuple_equality"Interval(1, 2) == (1, 2);
  • "cross_type_equality"Interval(1, 2) == Span(1, 2), asked through operator.eq (written literally it is a mypy comparison-overlap error — --strict-equality is, pleasingly, the one tool that does catch this hole, and only when both sides are statically known);
  • "same_kind"same_kind(Interval(1, 2), Span(1, 2)).

The last three document the contract rather than test your cleverness: two of them are True, and the only way to tell a Span from an Interval is type(a) is type(b). Write that helper deliberately — in real code it is the thing you reach for when a NamedTuple key needs to not collide.

No zero-argument super() and no __class__ anywhere inside these classes.