Python's famous flexibility cuts both ways. The language never checks what kind of value you hand to a function, so a routine that adds two integers will just as happily glue two strings together and hand back a result nobody asked for. The wrong answer appears with no error, and the defect surfaces far from the line that caused it — usually inside some other function that trusted the value it received.
Contrast that with a language that refuses to even run a call passing the wrong shape of data. Python trades that early safety for brevity and speed of writing. A type hint is how you win some of that safety back without giving the brevity up: a short note on a signature declaring what type each value should be, so editors, linters, and reviewers can catch a mismatch before the code ever runs.
flowchart LR
subgraph N["without hints"]
A1["call add(2, oops)"] --> A2["runs on, no warning"] --> A3["crash deep inside"]
end
subgraph H["with hints"]
B1["call add(2, oops)"] -.->|"static checker flags"| B2["error at the call site"]
end
style A3 fill:#b45309,color:#fff
style B2 fill:#3776ab,color:#fff
Reading an annotated signature
The syntax layers hints onto a definition you already know. Each parameter name gains a colon followed by a type, and the return value is announced with an arrow after the closing parenthesis.
def greet(name: str, times: int = 1) -> str:
return (name + "! ") * times
name: str declares that name should be a string; times: int says the count is a whole number; -> str promises the function hands back a string. None of these alter how the body runs. They are notes, read by people and by tooling, and quietly set aside by the interpreter while the code executes.
def greet(name: str, times: int = 1) -> str:
return (name + "! ") * times
print(greet("Hi"))
print(greet("Yo", 3))
Hints for collections
A single scalar type only gets you so far, and a plain int or str hint says nothing about what a list actually contains — which is usually the part that matters. Real functions pass lists, dictionaries, tuples, and values that might be missing, so the hint vocabulary grows to match. Since Python 3.9 you write collections with the very types you already use at runtime.
list[int]— a list whose elements are integers.dict[str, int]— a dictionary mapping strings to integers.tuple[int, str]— a fixed pair of an integer then a string.str | None— either a string or nothing at all.
That last one matters constantly. A function that searches and might come up empty returns str | None, which warns every caller to handle the missing case instead of assuming a value is always waiting.
flowchart LR P["name : str"] -->|"annotates the input"| F["the signature"] R["-> str"] -->|"annotates the output"| F style F fill:#3776ab,color:#fff
def count_by_parity(nums: list[int]) -> dict[str, int]:
counts = {"even": 0, "odd": 0}
for n in nums:
if n % 2 == 0:
counts["even"] += 1
else:
counts["odd"] += 1
return counts
print(count_by_parity([1, 2, 3, 4]))
Why bother, if nothing runs differently?
If Python never enforces a hint, what is the point? The value lives outside the runtime. Your editor uses hints to sharpen autocomplete and flag obvious mistakes as you type. A static checker such as mypy reads the whole project and reports type conflicts inside CI, before a human ever runs the code. And a good hint is documentation you can trust: instead of tracing the body to learn what comes back, you read -> dict[str, int] and understand at a glance. Because nothing executes differently, annotating a working program cannot introduce a bug — the worst it does is surface questions about code that was already ambiguous.
Gradual typing: a little at a time
Hints are opt-in, not all-or-nothing, which is why this is called a gradual system. You can annotate one critical function and leave the rest of a file untouched; a checker simply has nothing to say about the parts you skipped. That makes hints practical for an existing codebase. Add them where the risk is highest — public APIs and the functions many callers depend on — and let the rest catch up over time. A function with no annotations at all is treated as accepting Any, the type that silences the checker, so old code keeps working unchanged while new code grows steadily safer.
def double(x: int) -> int:
return x * 2
print(double(5))
print(double("hi"))
Optional values and the missing answer
Many functions can fail to find what you were looking for, and the honest return type is a value or nothing at all. Writing -> str | None makes that possibility impossible to overlook. Every caller who reads the signature is reminded to handle the empty case, and a checker will protest if they treat the result as though it were always a string. A return of str | None is therefore different in kind from str: the first demands a check, the second promises a value. The older spelling Optional[str] means exactly the same thing and still turns up in code written before Python 3.10, so recognise both forms when you meet them.
flowchart LR H["hints written in the file"] --> R["runtime: ignored"] H --> C["static checker: enforced"] R -.->|"no runtime error"| Bug["a bad call slips through"] C -.->|"error before it runs"| Caught["caught early, in CI"] style Bug fill:#b45309,color:#fff style Caught fill:#3776ab,color:#fff
Write format_price(price: float) -> str that returns a price formatted as a dollar string with two decimals — so format_price(9.5) gives "$9.50". Return the string; do not print.
def format_price(price: float) -> str:
# your code here
pass
An f-string with a format spec does this: f"${price:.2f}".
Make sure you return the string rather than printing it.
def format_price(price: float) -> str:
return f"${price:.2f}"
Hints change nothing. This function is fully annotated, but annotations never run. Predict the single line it prints.
def add(a: int, b: int) -> int:
return a + b
print(add(2, 3))
Annotations are notes — the body is ordinary addition.
2 + 3 is 5, and print writes it on one line.
This function is annotated to take an int, but it must actually REJECT non-integers at runtime — and a hint alone never does that. Add a guard so it raises TypeError for anything that is not an int, while still returning x * 2 for a genuine integer.
def double(x: int) -> int:
return x * 2
Use isinstance(x, int) to test the type at runtime.
If it fails the test, raise TypeError(...) before returning.
def double(x: int) -> int:
if not isinstance(x, int):
raise TypeError("x must be an int")
return x * 2
At runtime, what happens if you call a function declared def double(x: int) -> int with double("hi")?
Hints are advisory. Python ignores them at runtime, so "hi" * 2 is evaluated normally and concatenated into "hihi" — no error. Enforcement is the job of a separate static checker, not the interpreter.
Write first_even(nums: list[int]) -> int | None that returns the first even number in nums, or None if there is not one. The annotation matters: the return is int | None because the answer may not exist.
def first_even(nums: list[int]) -> int | None:
# your code here
pass
Loop through nums and return the first n where n % 2 == 0.
If the loop finishes without returning, there is no even number — return None.
def first_even(nums: list[int]) -> int | None:
for n in nums:
if n % 2 == 0:
return n
return None
Recap
- A type hint annotates a parameter (
name: str) or a return (-> str); it documents intent without changing how the code runs. - Use built-in generics (
list[int],dict[str, int]) andstr | Nonefor values that may be absent. - Hints are advice, not enforcement — Python ignores them at runtime; tools like mypy enforce them statically, in CI.
- The payoff is tooling and readability: sharper autocomplete, mismatches caught early, and signatures you can trust at a glance.
Next you will build flexible signatures with *args and **kwargs, so a single function can accept as many arguments as a caller cares to pass.