Python · Python 3.8 and 3.9

Python 3.8-3.9: Typing and the Standard Library

TypedDict, Final, Literal and Protocol - and what the interpreter doesn't check - generic built-ins, functools.cache and cached_property, zoneinfo and wall-clock arithmetic, and graphlib.

  • 5 min read
  • 11 practice questions
On this page
  1. Description
  2. Type hints are for tools
  3. Protocols: structural typing
  4. functools.cache and cached_property
  5. zoneinfo and datetime arithmetic
  6. graphlib
  7. Which tool for which job?
  8. Common mistakes
  9. Check yourself
  10. Key takeaways
  11. Quick revise
  12. Practice

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:

  1. What type hints do and don't do at run time.
  2. Protocols: structural typing, and what isinstance really checks.
  3. functools.cache and cached_property, and how each can bite.
  4. Time zones with zoneinfo, and why datetime arithmetic surprises people.
  5. graphlib for 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 plain dicts. Validate untrusted input with a library (Pydantic, msgspec) or by hand.
  • Final (3.8) marks a name as not to be reassigned - a test reassigned MAX_RETRIES: Final = 3 to 4 without error.
  • Literal["card", "paypal"] (3.8) restricts values for the checker; at run time pay("cash") returns "cash".
  • list[int], dict[str, int] (3.9, PEP 585) replace typing.List[int] and typing.Dict; at run time list[int] is a types.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. Calling rate("EUR") twice and rate("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 on self too, 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 cached total was still the old sum until del r.total reset it. It needs a __dict__, so on a class with __slots__ it raises TypeError: 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 timedelta to 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 gave 1 day, 0:00:00. For elapsed time, convert to UTC first: (noon.astimezone(timezone.utc) + timedelta(hours=24)).astimezone(ny) is 13:00. (Java's ZonedDateTime.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 with replace(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 toUse
Describe a JSON-like dict for the type checkerTypedDict
Check untrusted data at run timea 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 zoneszoneinfo.ZoneInfo
Add elapsed hoursarithmetic in UTC
Add calendar days ("same time tomorrow")arithmetic in the local zone
Order tasks by dependenciesgraphlib.TopologicalSorter

Common mistakes

MistakeWhat happensDo this instead
Trusting TypedDict, Final or Literal at run timebad data flows througha type checker in CI, plus validation at the edge
isinstance on a @runtime_checkable protocol as validationclose = 42 passescheck behavior, or use real validation
@cache on methods of long-lived objectsinstances never freedcache a module-level function, or bound it
cached_property on data that changesstale valuesa plain property, or del obj.attr on change
Adding hours to a local aware datetimean hour off across a clock changedo the arithmetic in UTC
replace(tzinfo=...) to converta different instantastimezone(...)

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; Protocol is structural typing.
  • cache is unbounded; cached_property computes 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.
  • graphlib orders tasks by their dependencies and finds cycles.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Pick the tool

You want toUse
Describe a dict's keys for the checkerTypedDict (NotRequired for optional keys)
Validate untrusted dataPydantic, msgspec, or explicit checks
Accept anything with the right methodsProtocol
Memoize a pure function@cache, or @lru_cache(maxsize=N)
Compute an attribute once@cached_property
Named time zonesZoneInfo("America/New_York")
Elapsed timearithmetic in UTC
Calendar time ("same time tomorrow")arithmetic in the local zone
Convert a zone / relabel a zoneastimezone() / replace(tzinfo=)
Order tasks by dependenciesgraphlib.TopologicalSorter

Snippets worth knowing by heart

class Closeable(Protocol):
    def close(self) -> None: ...

@functools.lru_cache(maxsize=1024)
def rate(currency: str) -> float:
    return fetch_rate(currency)

utc_noon = noon.astimezone(timezone.utc)
later = (utc_noon + timedelta(hours=24)).astimezone(ny)

order = list(graphlib.TopologicalSorter(deps).static_order())

The traps

  • TypedDict, Final, Literal - not enforced at run time.
  • isinstance on a runtime-checkable protocol - checks names exist, not behavior.
  • @cache on a method - keeps every instance alive.
  • cached_property on changing data - stale until del obj.attr; no __slots__.
  • + timedelta(hours=24) on a local datetime - wall-clock arithmetic.
  • replace(tzinfo=) to convert - a different instant.

Staff-level points, one line each

  • isinstance with a TypedDict or list[int] raises TypeError; list[int] is a GenericAlias.
  • Protocols with data members don't support issubclass; protocol isinstance is slow.
  • TypedDict records __required_keys__/__optional_keys__; validators read get_type_hints.
  • lru_cache on a method: one cache per function, keyed by self - instances leak.
  • cached_property is a non-data descriptor: the value in __dict__ shadows it; assignment overrides it.
  • Since 3.12 cached_property has no lock: four threads computed it four times.
  • Repeated hour: fold=0 first (UTC-4), fold=1 second (UTC-5), yet same-zone == says equal.
  • Missing hour: 02:30 is accepted with the old offset and round-trips to 03:30.
  • CycleError.args[1] is the cycle; prepare/get_ready/done schedule parallel work.

Say it in an interview

  • "The TypedDict is for the type checker - we still validate at the edge."
  • "I wouldn't put lru_cache on a method; the cache would keep every instance alive."
  • "Store UTC; local time only for display and calendar math."
  • "cached_property isn't locked since 3.12, so the getter must be safe to run twice."

Practice

This skill comes with 11 multiple-choice questions that tell you which misconception a wrong answer reveals. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice Python 3.8-3.9: Typing and the Standard Library free

Practice Python 3.8-3.9: Typing and the Standard Library free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime