skip to content

A TTL cache subclasses dict but a 6-hour nightly invoice-PDF run keeps serving stale values — how do you choose between dict, collections.UserDict and composition?

level: seniorimportance: should knowfreq 35%

answer

  1. Look at the write paths, not the clock
  2. Inherited methods are holes in the invariant
  3. Choose by whether an invariant must hold
  4. A cache is not really a mapping
  5. Test every entry point, not just assignment

basics

~20 s

The inherited dict methods never consult the expiry logic, so any caller using update(), setdefault() or the constructor writes past it. Choose by invariant: no invariant, subclass dict; overrides must always run, use collections.UserDict; a restricted surface, compose.

solid answer

~50 s

First name the cause: a `dict` subclass inherits C methods that write to the hash table without dispatching to your `__setitem__`, so entries inserted by `update()`, `setdefault()`, the `dict(...)` constructor or `|=` carry no expiry stamp and are served forever — over a 6-hour batch that means one stale value poisons the whole run. The decision rule is about invariants. If the class only *adds* behaviour and enforces nothing, subclassing `dict` is fine and gives you real-dict speed and compatibility. If an invariant must hold on every write, you cannot inherit a C container: use `collections.UserDict` (or `collections.abc.MutableMapping` over your own storage), where the mixin methods are Python and route through your overrides. If the object should not offer the full mapping surface at all, compose — hold a dict privately and expose only `__getitem__`, `__setitem__` and the few operations you are willing to guarantee.

code

python · 23 lines
python
import time

class TTLCache:
    def __init__(self, ttl):
        self._ttl = ttl
        self._entries = {}

    def __setitem__(self, key, value):
        self._entries[key] = (time.monotonic() + self._ttl, value)

    def __getitem__(self, key):
        expires_at, value = self._entries[key]
        if time.monotonic() >= expires_at:
            del self._entries[key]
            raise KeyError(key)
        return value

cache = TTLCache(ttl=-1.0)      # every entry is already expired
cache["invoice-42"] = b"%PDF-"
try:
    cache["invoice-42"]
except KeyError as exc:
    print("expired:", exc)

go deeper

for a junior

Take away the rule of thumb: if a class must control every write to its data, do not inherit from dict — inherited methods write straight past your code. Wrap a dict or use collections.UserDict instead.

for a middle

Be able to trace the bug end to end: which call inserted an entry without the expiry stamp, why the inherited method never reached __setitem__, and which of the three bases would have prevented it.

for a senior

Demonstrate diagnosis before design — name the bypass, list the entry points that trigger it, then choose a base against the invariant, and land the regression test that exercises every writer. Mention the monotonic clock and the boundary conversion.

for a principal

Own the guideline and its blast radius: when a mapping interface should be published at all, what a team's default container base is, and how you would find every existing built-in subclass in a codebase that silently enforces an invariant it cannot keep.

## Diagnosing it first The symptom is that a cache with expiry logic serves values that should have expired, and it only shows up on the long nightly run because a short interactive session never lives past the TTL. The instinct is to look at the clock arithmetic. Look at the *write paths* instead. A `dict` subclass inherits methods implemented in C that operate on the concrete hash table without dispatching to a Python-level `__setitem__`. So the expiry stamp is attached on `cache[key] = value` and on nothing else. A helper that does `cache.update(batch)`, a constructor call `TTLCache(preloaded)`, a `cache.setdefault(key, value)` in a get-or-compute helper, or a `cache |= more` all insert bare values. On read, `__getitem__` finds an entry with no timestamp — or with whatever shape your code expects — and either serves it forever or raises something obscure. Over a 6-hour run the stale entry is read thousands of times and the rendered output is wrong in a way that reproduces only at scale. The tell in review: the tests write with the subscript operator, and the production caller writes with `update()`. ## The three options, and what each buys **Subclass `dict`.** You get a real dict: `isinstance(obj, dict)` passes, C-level and serialisation APIs accept it, and every operation runs at built-in speed. What you do *not* get is interception. Every inherited method is a hole in whatever invariant you are trying to enforce, and the hole is silent. You can close the holes by overriding each mutating method, but that is a list you maintain by hand and one you can only be sure about by testing every entry point. **`collections.UserDict`.** It keeps a plain dict in its `data` attribute and derives from `collections.abc.MutableMapping`, so `update()`, `setdefault()`, `pop()` and friends are Python code written in terms of `__getitem__`/`__setitem__`/`__delitem__`. Override one and every path honours it, constructor included. The price: it is not an instance of `dict`, so serialisation and C-level consumers reject it and you convert at the boundary; and each operation costs several times a raw dict access. For a cache in front of an expensive render that is noise; for a hot inner loop it is not. One residual hole: `data` is public, so `obj.data[key] = value` still writes past you. **Composition.** Hold the dict in a private attribute and implement only the operations you are prepared to guarantee. Nothing is inherited, so nothing can bypass the expiry check; a caller who wants `update()` has to ask you for it, and you implement it in terms of your own `__setitem__`. The surface is smaller and honest, and the class stops pretending to be a general mapping when it is really a cache with two operations. The cost is boilerplate for every operation you *do* want, and no free `isinstance` compatibility — you expose a conversion method for the boundary instead. ## The decision rule Ask what the class is doing to the container's contract. * **Adding, not constraining** — extra convenience methods, no invariant on the data itself. Subclassing `dict` is defensible; the bypass costs you nothing because there is nothing to bypass. * **Constraining every write** — normalising keys, validating values, stamping a timestamp, recording an audit entry. Inheritance from a C container cannot deliver this. Use `UserDict`, or `MutableMapping` over storage you own if the storage should not be a plain public dict. * **A different concept wearing a mapping's clothes** — a cache is not a dict; it is a thing with `get`, `set` and eviction. Compose, and let the smaller interface document the difference. This is usually the right answer for caches specifically, because the mapping interface promises `len()`, iteration and `keys()` semantics that expiry makes ambiguous: is an expired-but-unswept entry in `len()`? ## Fixing this particular case Short term, stop the bleeding where it leaks: convert the class to `collections.UserDict` so the existing `update()` callers start routing through the stamping code, and add a regression test that writes through *every* entry point and asserts expiry — constructor, subscript, `update()`, `setdefault()`, `|=`. That test is the real deliverable; without it the next contributor reintroduces the hole. Longer term, ask whether the mapping interface belongs in the API at all. A cache exposing exactly `__getitem__`, `__setitem__` and an explicit `purge()` cannot be misused this way, and the long batch run becomes reasoning about two methods rather than about the whole `dict` surface. Also give the entries a monotonic clock source (`time.monotonic()`, not wall time) so a clock adjustment part-way through a long run cannot extend or shorten a TTL. ## What the interviewer is listening for The diagnosis before the design: name the bypass, name at least two entry points that trigger it, and only then reach for the option list. A candidate who jumps straight to "use UserDict" has the right answer to the wrong question, and cannot tell you when subclassing `dict` is still correct.

  • What regression test would you add so this cannot come back?
    One that writes through every entry point and asserts the invariant on each: the constructor, subscript assignment, `update()`, `setdefault()` and `|=`. Parametrise over the writers rather than testing one of them, because the original bug is precisely that the tested path and the used path differed. If the class is composed rather than inherited, the same test doubles as a check that the surface stayed small.
  • When is subclassing dict still the right call for a cache-like object?
    When you enforce no per-write invariant and genuinely need a real dict — you are handing the object to a C-level or serialisation API that type-checks for `dict`, or the access rate makes UserDict's Python-level indirection matter. If you add only helper methods and never need to intercept writes, the bypass costs you nothing.
  • Why prefer time.monotonic() over wall-clock time for the expiry stamp?
    `time.monotonic()` never goes backwards and is unaffected by system clock adjustments, so a clock correction during a long-running batch cannot silently extend or expire entries. Wall-clock time is right only when the deadline must be meaningful across processes or restarts; for an in-process TTL, a monotonic reference is the correct source.

Subclassing dict to enforce a rule is like adding a rule to a building's front-desk policy while every fire door still opens from the outside; composition is issuing one door and one key.

saying these in an interview costs you the question

  • Debugs the clock arithmetic and never checks write paths
  • Says subclassing dict is always wrong
  • Thinks calling super().__init__() would have fixed it
  • Ignores the isinstance and speed costs of UserDict
  • Proposes overriding one method and calling it closed
  • Assumes a mapping interface is free of design commitments

context