Skip to content

The total came out as “33”, three files away from the bug

  • Python · Core
  • 15 min read, about 1 h 10 min with practice
  • Concept
  • Asked often: medium
  • Reviewed 2026-11

A customer orders 3 mugs at £2 each. The order page shows a total of 33.

The bug took a day to find. The web form gives every value as text, so qty was "3", not 3. And in Python, 2 * "3" is not an error: it repeats the string, giving "33". Nothing failed until the total reached the payment code, three files away.

  • Input: a function, order_total(price, qty), and the code that calls it.
  • Output: a warning about the wrong type, as early as possible: ideally before the code runs at all.
  • Rules: no extra work on every call in production.
  • The hard part: Python doesn’t check types until an operation fails, and 2 * "3" doesn’t fail.

Hints open one at a time:

Hint 1 of 3

A docstring says it, but no tool reads docstrings.

Hint 2 of 3

isinstance checks catch it, but only when that line runs, and they run on every call.

Hint 3 of 3

Write qty: int in the def line. A checker such as mypy reads that and checks every caller.

Type hints label a function’s arguments and result:

def order_total(price: float, qty: int) -> float: ...
def find_user(users: dict[int, str], user_id: int) -> str | None: ...

Python stores them and ignores them. They go in order_total.__annotations__, and order_total(2, "3") still runs. The benefit comes from a type checker, such as mypy or pyright, which reads the code without running it:

$ mypy shop.py
shop.py:7: error: Argument 2 to "order_total" has incompatible type "str"; expected "int"

Editors run the same checks as you type, and underline the mistake in red.

HintMeans
int, str, floatThat class. A checker lets an int pass as a float.
list[int], dict[str, int]A list of ints, a dict from strings to ints.
str | NoneA string, or None. Older code writes Optional[str].
Callable[[int], str]A function that takes an int and returns a string.
T = TypeVar("T")“Some type”, the same one everywhere T appears.
AnyAnything; the checker stops checking.

All three run order_total in O(1). What changes is when the wrong type is found.

Okay

Say it in a docstring

The idea: Write the expected types in the docstring, and hope callers read it.

People reading the function learn the types.

def order_total(price, qty):
"""price is a float, qty is an int.""" # nothing checks this
return price * qty
form = {"qty": "3"} # values from a web form are always text
total = order_total(2, form["qty"]) # "33": a string, three files later
Time
O(1)
Space
O(1)
Why
No checks at all.

Where it breaks: no tool reads the docstring. order_total(2, "3") returns "33", and the bug shows up far away.

Better

isinstance on every call

The idea: Raise TypeError when an argument has the wrong type.

The waste in Okay: the wrong value travels on silently.

Now the error appears at the call, with a clear message.

Highlighted lines are new in this rung.

def order_total(price, qty):
if not isinstance(price, (int, float)):
raise TypeError(f"price must be a number, not {type(price).__name__}")
if not isinstance(qty, int):
raise TypeError(f"qty must be an int, not {type(qty).__name__}")
return price * qty # the checks run on every call, and only when it runs
Time
O(1) extra per call
Space
O(1)
Why
Two checks on every call, forever.
What changed from the previous rung
def order_total(price, qty):
"""price is a float, qty is an int.""" # nothing checks this
return price * qty
form = {"qty": "3"} # values from a web form are always text
total = order_total(2, form["qty"]) # "33": a string, three files later
if not isinstance(price, (int, float)):
raise TypeError(f"price must be a number, not {type(price).__name__}")
if not isinstance(qty, int):
raise TypeError(f"qty must be an int, not {type(qty).__name__}")
return price * qty # the checks run on every call, and only when it runs

Where it breaks: it only fails when that call runs, maybe first in production. And every function needs these lines, which run on every call.

Best

Type hints and a checker

The idea: Write price: float, qty: int -> float, and let mypy check every caller.

The waste in Better: a run-time check for a mistake you could find before running.

mypy reports order_total(2, form["qty"]) because form is dict[str, str]. The fix, int(form["qty"]), goes where the text comes in.

Highlighted lines are new in this rung.

def order_total(price: float, qty: int) -> float:
return price * qty
form: dict[str, str] = {"qty": "3"}
# mypy, before the code runs:
# error: Argument 2 to "order_total" has incompatible type "str"; expected "int"
total = order_total(2, int(form["qty"])) # the fix mypy pushed us to
Time
O(1)
Space
O(1)
Why
The check happens before the code runs, so calls cost nothing extra.
What changed from the previous rung
def order_total(price, qty):
if not isinstance(price, (int, float)):
raise TypeError(f"price must be a number, not {type(price).__name__}")
if not isinstance(qty, int):
raise TypeError(f"qty must be an int, not {type(qty).__name__}")
return price * qty # the checks run on every call, and only when it runs
def order_total(price: float, qty: int) -> float:
return price * qty
form: dict[str, str] = {"qty": "3"}
# mypy, before the code runs:
# error: Argument 2 to "order_total" has incompatible type "str"; expected "int"
total = order_total(2, int(form["qty"])) # the fix mypy pushed us to

When Best isn’t best: hints don’t check data from outside, like JSON or a form, at run time. Check that at the edge, with code or a library such as Pydantic.

Hints are just data, so you can read them. Write wrong_args(func, *args, **kwargs), which returns the names of the arguments whose values don’t match their hints. Skip arguments without a hint.

Hint 1 of 3

inspect.signature(func).bind(*args, **kwargs).arguments maps each name to its value, keyword or not.

Hint 2 of 3

typing.get_type_hints(func) maps names to types, and turns hints written as text, like "int", into real types.

Hint 3 of 3

isinstance(value, int | None) works on X | None hints.

Which arguments break their hints?

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: wrong_args(price, 9.5, "3") → ['qty']
  2. Not run yet: wrong_args(price, "x", qty="y") → ['amount', 'qty']
  3. Not run yet: a has no hint: wrong_args(loose, "x", "y") → ['b']
  4. Not run yet: a hint written as text: n: "int" → ['n']
  5. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show an answer
check_args.py
import inspect
import typing
from collections.abc import Callable
def wrong_args(func: Callable, *args: object, **kwargs: object) -> list[str]:
hints = typing.get_type_hints(func) # turns "int" strings into real types
bound = inspect.signature(func).bind(*args, **kwargs) # match values to names
return [
name
for name, value in bound.arguments.items()
if name in hints and not isinstance(value, hints[name])
]

bind matches values to names the way a real call does. get_type_hints resolves text hints, which is what you get in files that start with from __future__ import annotations.

WayFinds order_total(2, "3")Cost per callUse it when
Okay: a docstringNeverNoneNever on its own
Better: isinstance checksWhen that call runsTwo checksData from outside, at the edge
Best: hints and mypyBefore the code runsNoneAll your own code
  • A mutable default hides None: def f(xs: list[int] = None) is wrong. Write xs: list[int] | None = None.
  • float accepts int for a checker, because ints work anywhere floats do. isinstance(3, float) is still False.
  • list[int] needs Python 3.9+, and X | None needs 3.10+. Older code imports List and Optional from typing.
  • Any turns checking off. Use object when you mean “anything, and I won’t use it as a specific type”.
  • Use Sequence[T] or Iterable[T] for arguments, not list[T], so tuples and generators are accepted too.

Questions to ask first

  • Does the team run mypy or pyright in CI, and how strict?
  • Where does outside data come in, so I check it at run time there?

A 60-second walkthrough

“Python only finds a wrong type when an operation fails, and 2 * '3' doesn’t fail: it gives '33'. Type hints label the signature: price: float, qty: int -> float. Python stores them in __annotations__ but never checks them. The value comes from mypy or pyright, which read the code without running it. They flag order_total(2, form['qty']), because the form gives strings. That costs nothing per call. For data from outside, like JSON, hints can’t help at run time, so I’d check it where it enters, with code or Pydantic.”

Likely follow-ups

  • What is a TypeVar for? When the result’s type follows an argument’s: first(items: Sequence[T]) -> T returns an int for a list of ints.
  • What is a Protocol? A type defined by methods, not by inheritance: anything with a .read() method fits. The ABCs and Protocols lesson covers it.
  • Do hints make Python faster? No. CPython ignores them; tools like mypyc can compile hinted code, but that’s separate.

Three problems, from easy to hard. Each has hints, tests you can run here, and its own Okay, Better and Best answers. Try before you open them.

find_user(users, user_id) returns the user’s name, or None if there is no such user. Add hints: users is a dict from ints to strings. The last test reads your return hint.

Hint 1 of 3

Hint users as a dict from ints to strings, and user_id as an int.

Hint 2 of 3

dict.get returns None for a missing key, so the result can be None.

Hint 3 of 3

The return hint is str | None.

A hint that tells the truth

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: find_user({1: "ana"}, 1) → 'ana'
  2. Not run yet: find_user({1: "ana"}, 2) → None
  3. Not run yet: is the return hint str | None? → True
Try your own input
Show the Okay, Better and Best answers
Okay

No hints

The idea: Return users.get(user_id).

It works.

def find_user(users, user_id): # no hints yet
return users.get(user_id)
Time
O(1)
Space
O(1)
Why
One dict look-up.

Where it breaks: a caller who writes find_user(u, 2).upper() gets no warning until it crashes on None.

Better

-> str

The idea: Hint the result as str.

The waste in Okay: callers have to guess the types.

Highlighted lines are new in this rung.

def find_user(users: dict[int, str], user_id: int) -> str: # wrong: can be None
return users.get(user_id) # mypy: incompatible return value "str | None"
Time
O(1)
Space
O(1)
Why
One dict look-up.
What changed from the previous rung
def find_user(users, user_id): # no hints yet
return users.get(user_id)
def find_user(users: dict[int, str], user_id: int) -> str: # wrong: can be None
return users.get(user_id) # mypy: incompatible return value "str | None"

Where it breaks: the hint lies. mypy flags the return line, and callers trust a str that is sometimes None.

Best

-> str | None

The idea: The honest hint: the result can be None.

The waste in Better: a hint that hides the one case callers must handle.

Now mypy makes every caller check for None before using the name.

Highlighted lines are new in this rung.

def find_user(users: dict[int, str], user_id: int) -> str | None:
return users.get(user_id) # mypy now makes callers check for None
Time
O(1)
Space
O(1)
Why
One dict look-up.
What changed from the previous rung
def find_user(users: dict[int, str], user_id: int) -> str: # wrong: can be None
return users.get(user_id) # mypy: incompatible return value "str | None"
def find_user(users: dict[int, str], user_id: int) -> str | None:
return users.get(user_id) # mypy now makes callers check for None

When Best isn’t best: if a missing user is a bug, not a normal case, raise KeyError and keep the hint -> str.

first(items, default) returns the first item, or default if items is empty. Hint it so a checker knows first([3, 4], 0) is an int and first("hi", "?") is a string. The last test checks for a TypeVar.

Hint 1 of 3

Any works, but then the checker knows nothing about the result.

Hint 2 of 3

A hint for lists of ints is too narrow: strings and tuples are refused.

Hint 3 of 3

T = TypeVar("T"), then items: Sequence[T], default: T, -> T.

First item, any type

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: first([3, 4], 0) → 3
  2. Not run yet: first('hi', '?') → 'h'
  3. Not run yet: first((), None) → None
  4. Not run yet: are the result and default the same TypeVar? → True
Try your own input
Show the Okay, Better and Best answers
Okay

Any

The idea: Hint everything as Any.

The checker stops complaining.

from typing import Any
def first(items: Any, default: Any) -> Any:
return items[0] if items else default
Time
O(1)
Space
O(1)
Why
One index.

Where it breaks: first([3], 0).upper() passes the checker too, because Any turns checking off.

Better

Only lists of ints

The idea: Hint the one case you had in mind.

The waste in Okay: it says nothing about the result.

Highlighted lines are new in this rung.

def first(items: list[int], default: int) -> int: # only lists of ints
return items[0] if items else default
Time
O(1)
Space
O(1)
Why
One index.
What changed from the previous rung
from typing import Any
def first(items: Any, default: Any) -> Any:
def first(items: list[int], default: int) -> int: # only lists of ints
return items[0] if items else default

Where it breaks: the checker now refuses first("hi", "?") and first((1,), 0), which work fine.

Best

A TypeVar

The idea: T stands for one type: Sequence[T] in, T out.

The waste in Better: a hint narrower than the code.

The checker works out T at each call: int for [3, 4], str for "hi".

Highlighted lines are new in this rung.

from collections.abc import Sequence
from typing import TypeVar
T = TypeVar("T") # "some type", the same one everywhere it appears
def first(items: Sequence[T], default: T) -> T:
return items[0] if items else default
Time
O(1)
Space
O(1)
Why
One index.
What changed from the previous rung
def first(items: list[int], default: int) -> int: # only lists of ints
from collections.abc import Sequence
from typing import TypeVar
T = TypeVar("T") # "some type", the same one everywhere it appears
def first(items: Sequence[T], default: T) -> T:
return items[0] if items else default

When Best isn’t best: on Python 3.12 and later, write def first[T](items: Sequence[T], default: T) -> T: without the TypeVar line.

Write matches(value, hint), which says whether a value fits a hint, looking inside: [1, "a"] does not fit list[int]. Handle plain classes, list[X], dict[K, V] and X | None.

Hint 1 of 3

isinstance() raises TypeError when the hint has brackets, like a list of ints.

Hint 2 of 3

typing.get_origin(hint) gives the outer class, like list. typing.get_args(hint) gives what is inside, like (int,).

Hint 3 of 3

For a union, get_origin is types.UnionType or typing.Union: match any of the args.

Check inside a list

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: matches(3, int) → True
  2. Not run yet: matches([1, 2], list[int]) → True
  3. Not run yet: matches([1, "a"], list[int]) → False
  4. Not run yet: matches({"a": ["x"]}, dict[str, list[int]]) → False
  5. Not run yet: Extra test
  6. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

isinstance

The idea: isinstance(value, hint).

Right for plain classes and for int | None.

def matches(value: object, hint: type) -> bool:
return isinstance(value, hint) # TypeError on list[int]
Time
O(1)
Space
O(1)
Why
One check.

Where it breaks: isinstance([1], list[int]) raises TypeError: isinstance() argument 2 cannot be a parameterized generic.

Better

The outer class

The idea: Check the value against get_origin(hint): the outer class, like list.

The waste in Okay: it can’t take generic hints at all.

Highlighted lines are new in this rung.

import typing
def matches(value: object, hint: object) -> bool:
outer = typing.get_origin(hint) or hint # list[int] -> list
return isinstance(value, outer) # never looks inside
Time
O(1)
Space
O(1)
Why
One check.
What changed from the previous rung
def matches(value: object, hint: type) -> bool:
return isinstance(value, hint) # TypeError on list[int]
import typing
def matches(value: object, hint: object) -> bool:
outer = typing.get_origin(hint) or hint # list[int] -> list
return isinstance(value, outer) # never looks inside

Where it breaks: it never looks inside, so ["a"] fits list[int]. And int | None has the origin UnionType, which no value is an instance of.

Best

Look inside, one level at a time

The idea: Split the hint with get_origin and get_args, and call matches on each item.

The waste in Better: it trusts the outside of the box.

Highlighted lines are new in this rung.

import types
import typing
def matches(value: object, hint: object) -> bool:
origin = typing.get_origin(hint)
if origin is None: # a plain class like int
return isinstance(value, hint)
args = typing.get_args(hint)
if origin in (typing.Union, types.UnionType): # int | None
return any(matches(value, a) for a in args)
if not isinstance(value, origin):
return False
if origin is list:
return all(matches(v, args[0]) for v in value)
if origin is dict:
key, val = args
return all(matches(k, key) and matches(v, val) for k, v in value.items())
return True # other generics: the outer check is all we do
Time
O(n)
Space
O(d)
Why
Each of the n items inside is checked once; the recursion is d levels deep.
What changed from the previous rung
import types
import typing
def matches(value: object, hint: object) -> bool:
outer = typing.get_origin(hint) or hint # list[int] -> list
return isinstance(value, outer) # never looks inside
origin = typing.get_origin(hint)
if origin is None: # a plain class like int
return isinstance(value, hint)
args = typing.get_args(hint)
if origin in (typing.Union, types.UnionType): # int | None
return any(matches(value, a) for a in args)
if not isinstance(value, origin):
return False
if origin is list:
return all(matches(v, args[0]) for v in value)
if origin is dict:
key, val = args
return all(matches(k, key) and matches(v, val) for k, v in value.items())
return True # other generics: the outer check is all we do

When Best isn’t best: real checkers handle far more: tuples, TypedDict, Literal. Use Pydantic or typeguard rather than growing this.

A few quick questions. Ones you miss are remembered, so you can find your weak spots later.

  1. Predict the outputWhat does this print?
    def double(x: int) -> int:
        return x * 2
    
    print(double("ab"))
  2. Multiple choiceA function returns a name, or None when there is none. Which return hint is right?
  3. Spot the bugmypy complains about this signature. Why?
    def add_tags(tags: list[str] = None) -> list[str]:
        return (tags or []) + ["new"]
  4. Multiple choiceWhich hint lets a checker know first([3, 4], 0) returns an int, and first("hi", "?") a str?

Add these to your review deck. Each card comes back a little later every time you get it right.

  • def f(x: int, names: list[str]) -> bool:.
  • str | None for “maybe missing”; default None needs it.
  • Sequence[T], Iterable[T] for arguments; TypeVar to link types.
  • Python ignores hints; mypy or pyright check them.
  • typing.get_type_hints(f) reads them at run time.

Can you do these now?

If you’re not sure of any of these, scroll back to that part before moving on.

Status: Not started