On this page
Description
Python 3.8 and 3.9 added a lot of typing vocabulary - Protocol, TypedDict, Literal, Final, list[int] - and a handful of standard-library tools you'll meet in every codebase: functools.cache, cached_property, zoneinfo and graphlib. Each one has rules that differ from what its name suggests, and those differences are what interviews ask about.
Here's the kind of bug we mean. A payment service declares its order shape:
class Order(TypedDict):
id: str
cents: int
order: Order = json.loads(body) # {"id": "A1", "cents": "twelve"}
total += order["cents"] # TypeError far away, or worse, string concatenation
The team assumed TypedDict validates. It doesn't: at run time it's a plain dict, and a test stored "twelve" in cents without complaint.
We'll go from simple to more technical:
- What type hints do and don't do at run time.
- Protocols: structural typing, and what
isinstancereally checks. functools.cacheandcached_property, and how each can bite.- Time zones with
zoneinfo, and why datetime arithmetic surprises people. graphlibfor ordering tasks by their dependencies.
Type hints are for tools
Annotations are stored on functions and classes but not enforced by the interpreter:
TypedDict(3.8) describes the keys and value types of a dict for a type checker; instances are plaindicts. Validate untrusted input with a library (Pydantic,msgspec) or by hand.Final(3.8) marks a name as not to be reassigned - a test reassignedMAX_RETRIES: Final = 3to 4 without error.Literal["card", "paypal"](3.8) restricts values for the checker; at run timepay("cash")returns"cash".list[int],dict[str, int](3.9, PEP 585) replacetyping.List[int]andtyping.Dict; at run timelist[int]is atypes.GenericAlias.
So run a type checker (mypy, pyright) in CI - that's where these features pay off.
Protocols: structural typing
class Closeable(Protocol):
def close(self) -> None: ...
def shutdown(resources: list[Closeable]) -> None:
for r in resources:
r.close()
Any class with a close() method satisfies Closeable - no inheritance or registration needed. That's how Python's "duck typing" gets a static type. It's the counterpart of a Java interface, minus the implements.
With @runtime_checkable, isinstance(x, Closeable) works too, but it only checks that the members exist - a class with close = 42 passed the check in a test. Use it as a cheap sanity check, not as validation.
functools.cache and cached_property
@functools.cache(3.9) memoizes a function with an unbounded dict. Callingrate("EUR")twice andrate("GBP")once computed only twice (hits=1, misses=2).@lru_cache()is the bounded version, 128 entries by default. Arguments must be hashable. Decorating a method caches onselftoo, which keeps every instance alive as long as the cache - a memory leak in long-running services.@functools.cached_property(3.8) computes an attribute on first access and stores the result in the instance's__dict__; later accesses never call the function again. So it doesn't notice changes: after appending a row, a test's cachedtotalwas still the old sum untildel r.totalreset it. It needs a__dict__, so on a class with__slots__it raisesTypeError: No '__dict__' attribute on 'Slotted' instance to cache 'total' property.Use it for values derived from state that doesn't change.
zoneinfo and datetime arithmetic
zoneinfo.ZoneInfo("America/New_York") (3.9) gives IANA time zones from the system's tz database (or the tzdata package). Two rules trip people up:
- Same-zone arithmetic is wall-clock arithmetic. Adding a
timedeltato an aware datetime changes the date and time fields and then works out the offset again. From noon on 2026-03-07 in New York (clocks spring forward that night),+ timedelta(hours=24)gave 12:00 on 03-08 - the same as+ timedelta(days=1)- though only 23 hours had passed; subtracting the two noons gave1 day, 0:00:00. For elapsed time, convert to UTC first:(noon.astimezone(timezone.utc) + timedelta(hours=24)).astimezone(ny)is 13:00. (Java'sZonedDateTime.plus(Duration.ofHours(24))does the elapsed-time version - see Java 8: Optional, java.time and the Rest.) replace(tzinfo=...)relabels;astimezone(...)converts. 09:30 UTC withreplace(tzinfo=ny)becomes 09:30 New York time - a different instant;astimezone(ny)gives 05:30 New York, the same instant.
Store timestamps as UTC-aware datetimes, and convert to a zone only to display them or to do calendar arithmetic ("same time tomorrow").
graphlib
graphlib.TopologicalSorter (3.9) orders tasks so each comes after its dependencies - {"deploy": {"build", "test"}, "test": {"build"}} gives ['build', 'test', 'deploy'] - and raises CycleError on a cycle. It also supports running independent tasks in parallel (prepare, get_ready, done): a build system's scheduling loop, in the standard library.
Which tool for which job?
| You want to | Use |
|---|---|
| Describe a JSON-like dict for the type checker | TypedDict |
| Check untrusted data at run time | a validation library (Pydantic, msgspec) or explicit checks |
Accept "anything with a close()" | a Protocol |
| Memoize a pure function | @functools.cache (or @lru_cache(maxsize=N) to bound it) |
| Compute an attribute once, on demand | @functools.cached_property |
| Work with named time zones | zoneinfo.ZoneInfo |
| Add elapsed hours | arithmetic in UTC |
| Add calendar days ("same time tomorrow") | arithmetic in the local zone |
| Order tasks by dependencies | graphlib.TopologicalSorter |
Common mistakes
| Mistake | What happens | Do this instead |
|---|---|---|
Trusting TypedDict, Final or Literal at run time | bad data flows through | a type checker in CI, plus validation at the edge |
isinstance on a @runtime_checkable protocol as validation | close = 42 passes | check behavior, or use real validation |
@cache on methods of long-lived objects | instances never freed | cache a module-level function, or bound it |
cached_property on data that changes | stale values | a plain property, or del obj.attr on change |
| Adding hours to a local aware datetime | an hour off across a clock change | do the arithmetic in UTC |
replace(tzinfo=...) to convert | a different instant | astimezone(...) |
Check yourself
Try to answer before opening each one - recalling beats re-reading.
1. Does order: Order = {"id": "A1", "cents": "twelve"} raise when Order is a TypedDict with cents: int?
Show answer
No - at run time it's a plain dict. Only a type checker (or explicit validation) catches it.
2. Why does a cached_property keep returning the old total after the list changes?
Show answer
It stores the computed value in the instance's __dict__ on first access and never calls the function again. Reset it with del obj.total, or don't cache values derived from mutable state.
3. From noon in New York the day before clocks spring forward, what does + timedelta(hours=24) give?
Show answer
Noon the next day - Python does wall-clock arithmetic within a zone, so only 23 real hours pass. Add in UTC to get 13:00.
Key takeaways
- Type hints are checked by tools, not by Python;
Protocolis structural typing. cacheis unbounded;cached_propertycomputes once and stores the value in__dict__.- Aware datetimes in one zone use wall-clock arithmetic - do durations in UTC.
replace(tzinfo=)relabels,astimezone()converts.graphliborders tasks by their dependencies and finds cycles.