1. Home
  2. Blog
  3. Software Development

Software Development

Python Type Hints Explained: Annotations, mypy and the Errors It Catches

Type hints let you say what kind of value a function expects and returns. Python ignores them at runtime, but a checker like mypy uses them to catch bugs before your code ever runs. We ran mypy on real examples to show what it finds.

10 min read
Python type hints explained: annotations, mypy and the errors it catches

Key takeaways

  • Type hints describe expected types, such as def total(price: float) -> float.
  • Python does not check them at runtime. A type checker such as mypy or Pyright does.
  • In our run, mypy found 9 errors in a short file, including a possible None bug and a typo in a dictionary key.
  • Unannotated functions are not checked by default, so you can add hints gradually.

A function receives a string where it expected a number, and the program crashes in production, three calls away from where the mistake was made. Type hints exist to catch that class of bug earlier: in your editor, or in a CI check, before anyone runs the code.

We wrote a short file full of deliberate mistakes and checked it with mypy 2.4 on Python 3.13. Every error message below is copied from that run.

What are type hints?

Type hints, also called type annotations, are optional labels that describe the type of a variable, a parameter or a return value. They were introduced in Python 3.5 by PEP 484 and have been extended in almost every release since. They are written with a colon after a name and an arrow for the return type:

def apply_discount(price: float, percent: int) -> float:
    return round(price * (1 - percent / 100), 2)

Anyone reading this knows what to pass and what comes back, without reading the body. Editors use the same information for autocomplete and inline warnings.

Hints are not enforced at runtime

This is the most important fact to understand. Python stores the annotations, but it does not check them when the code runs. Assigning the float result to a variable annotated as int ran without complaint. Passing the string "100" did fail, but only because multiplying a string by a float is impossible:

apply_discount("100", 10)
# TypeError: can't multiply sequence by non-int of type 'float'

That error came from the arithmetic, not from the hint. With a different body, the wrong type could have slipped through silently. Checking hints is the job of a separate tool. Libraries such as Pydantic do validate data against hints at runtime, which is useful at system boundaries like API inputs.

Basic annotations

Hint Meaning
int, float, str, bool Simple built-in types
list[str] A list of strings (Python 3.9+)
dict[str, int] A dictionary from strings to integers
tuple[int, str] A pair: an int then a str
str | None A string or None (Python 3.10+)
-> None The function returns nothing useful

Older code uses List, Dict and Optional imported from typing. They still work, but the built-in forms are preferred in modern Python. Variables can be annotated too, as in total: int = 0, though most of the time the checker infers variable types for you.

Running mypy

Install it with pip install mypy and point it at a file or folder. Our file produced this summary:

$ mypy hints.py
hints.py:7: error: Incompatible types in assignment (expression has type "float", variable has type "int")  [assignment]
hints.py:8: error: Argument 1 to "apply_discount" has incompatible type "str"; expected "float"  [arg-type]
...
Found 9 errors in 1 file (checked 1 source file)

Each message gives the line, a plain description and an error code in brackets. The code is useful for silencing one specific check with a comment such as # type: ignore[arg-type], which is far safer than ignoring every check on that line. mypy exits with status 1 when it finds errors, so it can fail a CI pipeline just like a failing test.

Optional values and None

The most valuable thing a type checker does is remind you that something might be None:

def find_user(user_id: int) -> str | None:
    return {1: "Asha"}.get(user_id)

name = find_user(2)
print(name.upper())
# error: Item "None" of "str | None" has no attribute "upper"  [union-attr]

if name is not None:
    print(name.upper())   # no error

At runtime, the first print would raise AttributeError for a missing user, which is exactly the kind of bug that hides until real data arrives. After the is not None check, mypy narrows the type to str and the error disappears. This narrowing also works with isinstance() and early returns.

Collections and inference

For an empty list or dictionary, the checker cannot guess what will go in it, so annotate it: orders: list[dict[str, float]] = []. For function parameters, prefer the most general type that works. Accepting Iterable[str] instead of list[str] lets callers pass a list, a tuple, a set or a generator, while still telling the checker that the items are strings.

TypedDict and Literal

Dictionaries from JSON often have a fixed set of keys. TypedDict describes them, and mypy then checks the keys:

class Order(TypedDict):
    id: int
    total: float

o: Order = {"id": 1, "totl": 9.5}
# error: Missing key "total" for TypedDict "Order"  [typeddict-item]
# error: Extra key "totl" for TypedDict "Order"  [typeddict-unknown-key]

A one-letter typo produced two clear errors. Literal restricts a value to a fixed set of options, like an enum for strings:

Status = Literal["paid", "pending"]
def set_status(s: Status) -> None: ...

set_status("refunded")
# error: Argument 1 to "set_status" has incompatible type "Literal['refunded']";
#        expected "Literal['paid', 'pending']"  [arg-type]

These two are where hints pay off fastest in real code, because typos in keys and status strings are common and tests do not always cover them.

Protocols: duck typing with checks

Python code often cares only that an object has the right methods, not what class it is. A Protocol describes that shape:

class Notifier(Protocol):
    def send(self, msg: str) -> bool: ...

class Email:
    def send(self, msg: str) -> bool: return True

class Broken:
    def send(self, msg: int) -> bool: return True

alert(Email())    # fine, no inheritance needed
alert(Broken())
# error: Argument 1 to "alert" has incompatible type "Broken"; expected "Notifier"
# note:     Expected: def send(self, msg: str) -> bool
# note:     Got:      def send(self, msg: int) -> bool

Email never mentions Notifier, yet it is accepted because its send method matches. This is structural typing, the same idea TypeScript uses, as our interface vs type guide shows. Protocols also make testing easier, because a simple fake object with the right methods satisfies the type, which pairs well with mocking in Python.

Generics

A generic function keeps the connection between input and output types:

T = TypeVar("T")
def first(items: Iterable[T]) -> T: ...

reveal_type(first([1, 2, 3]))   # note: Revealed type is "int"
reveal_type(first(["a"]))       # note: Revealed type is "str"

reveal_type() is a special helper that only the checker understands; it prints what mypy thinks a type is, which is handy when an error is confusing. Python 3.12 added a shorter syntax, def first[T](items: Iterable[T]) -> T, which means the same thing.

Callable: typing functions you pass around

def run(cb: Callable[[int], str]) -> str:
    return cb(5)

run(lambda n: n * 2)
# error: Argument 1 to "run" has incompatible type "Callable[[int], int]";
#        expected "Callable[[int], str]"  [arg-type]

The callback returned a number where a string was required. Callbacks are a common source of mismatches, especially with lambda functions, whose types are inferred silently.

Gradual typing and strict mode

We left one deliberate mistake unflagged:

def untyped(x):
    return x + 1

untyped("oops")

By default mypy treats an unannotated function as returning and accepting Any, so it did not report this call. That is intentional. It lets you add hints to an existing project a little at a time without a flood of errors. Running mypy --strict changed the result:

hints.py:64: error: Function is missing a type annotation  [no-untyped-def]
hints.py:66: error: Call to untyped function "untyped" in typed context  [no-untyped-call]

Strict mode is a good goal for new code and libraries. For older code, turn on individual strict options one at a time in your configuration.

Tip: Treat Any as a hole in your safety net. Every Any, whether written by you or inferred from an unannotated library, switches off checking for that value. Prefer object when you genuinely accept anything, since mypy then forces you to check the type before using it.

Adopting hints in an existing project

  1. Start at the edges. Annotate public functions, API handlers and data models first; they give the biggest return.
  2. Add mypy to CI with a config file, for example a [tool.mypy] section in pyproject.toml, so new errors fail the build.
  3. Use per-module settings to apply stricter rules to new packages while legacy ones catch up.
  4. Install type stubs for libraries that need them, such as types-requests.
  5. Model your data with TypedDict or dataclasses instead of loose dictionaries.

Which type checker?

Tool Notes
mypy The original checker, widely used in CI, highly configurable
Pyright Fast, powers VS Code’s Pylance extension
Newer Rust-based checkers Very fast; check their maturity for your project

Different checkers sometimes disagree on edge cases, so pick one for CI and let your editor use whichever it ships with. Only mypy was run for this article.

Final thoughts

Type hints are documentation that a tool can verify. They cost a few characters per function and catch real bugs: values that might be None, misspelt keys, wrong callback shapes and unexpected status strings. Start with the functions other people call, run mypy in CI, and tighten the settings as coverage grows.

Frequently asked questions

Does Python enforce type hints?

No. Python stores annotations but does not check them at runtime. A static type checker such as mypy or Pyright checks them, and libraries such as Pydantic can validate data at runtime.

What is mypy?

mypy is a static type checker for Python. It reads your type hints and reports mismatches, such as passing a string where a number is expected, without running the code.

What is the difference between Optional[str] and str | None?

They mean the same thing: a string or None. str | None is the newer syntax, available from Python 3.10.

Do type hints make Python faster?

No. Standard Python ignores them at runtime, so performance is unchanged. Their value is catching bugs and improving editor support.

Why does mypy not report errors in my function?

Functions without annotations are treated as Any and are not checked by default. Add annotations or run mypy with –strict or –check-untyped-defs.

Keep learning

Work with Stack Quora

Need help with testing, WordPress or SEO?

We help teams ship better software and grow their sites. Tell us what you are working on and we will suggest a clear next step.

Keep reading

More guides on testing, AI & modern development

Enjoyed this article? Pick a topic and jump straight into our newest tutorials.

Artificial Intelligence

LLM Structured Output Explained: Getting Reliable JSON From AI Models

LLM structured output explained with validation we ran in Pydantic: JSON mode and schemas, constrained decoding, the ways model output…

Read article →
DevOps

SSH Keys Explained: How They Work, ssh-keygen, Ed25519 and Safe Habits

SSH keys explained with keys we generated: public vs private keys, ssh-keygen, Ed25519 vs RSA, fingerprints, passphrases, ssh-agent, authorized_keys, config…

Read article →
DevOps

HTTP Caching Explained: Cache-Control, ETag and 304 Responses, With a Live Test

HTTP caching explained with a small server we ran: Cache-Control directives, max-age, no-cache vs no-store, ETag revalidation and 304 responses,…

Read article →
Test Automation

API Contract Testing Explained: Catch Breaking Changes Before They Ship

API contract testing explained with a schema check we ran: what a contract is, consumer-driven contracts, Pact, OpenAPI, which changes…

Read article →
Software Testing

Risk-Based Testing Explained: Score Risks, Build a Matrix and Test What Matters

Risk-based testing explained step by step: identifying risks, scoring likelihood and impact, building a risk matrix, mapping risk to test…

Read article →
Databases & SQL

SQL Constraints Explained: NOT NULL, UNIQUE, CHECK and FOREIGN KEY, Tested

SQL constraints explained with tests we ran: NOT NULL, UNIQUE, CHECK, DEFAULT, PRIMARY and FOREIGN KEY, the NULL loopholes, SQLite's…

Read article →
Databases & SQL

SQL Date Functions Explained: Adding, Comparing and Grouping Dates Without Bugs

SQL date functions explained with queries we ran: adding intervals, month-end surprises, date differences, the BETWEEN trap, grouping by day,…

Read article →
Software Development

Python asyncio Explained: async, await, gather and TaskGroup With Real Timings

Python asyncio explained with code we timed: coroutines, await, gather, TaskGroup, error handling, timeouts, limiting concurrency and the blocking-call mistake…

Read article →
Software Development

Debounce vs Throttle in JavaScript: How They Work, When to Use Each, Tested

Debounce vs throttle in JavaScript explained with timings we measured in Node.js: how each works, simple implementations, leading and trailing…

Read article →
Software Development

Python Context Managers Explained: The with Statement, contextlib and Real Uses

Python context managers explained with code we ran: how with works, __enter__ and __exit__, contextlib.contextmanager, suppress, ExitStack, database transactions and…

Read article →
Artificial Intelligence

LLM Temperature and Top-p Explained: How AI Models Choose Their Next Word

LLM temperature and top-p explained with a simulation we ran: how probabilities are reshaped, what top-p keeps, recommended settings by…

Read article →
DevOps

YAML Explained: Syntax, the Norway Problem and Other Traps We Tested

YAML explained with a parser test: basic syntax, lists and maps, the Norway problem, versions turning into floats, octal zip…

Read article →
DevOps

Cron Jobs Explained: Syntax, Real Schedules and the Mistakes That Break Them

Cron jobs explained with schedules we verified: the five fields, operators, real examples, the day-of-month OR day-of-week trap, environment problems,…

Read article →
Software Testing

Bug Life Cycle in Software Testing: Every Status, Who Owns It and Common Mistakes

The bug life cycle explained: every defect status from New to Closed, who owns each step, rejected, deferred and reopened…

Read article →
Test Automation

Property-Based Testing in Python: Hypothesis Finds the Bugs Your Examples Miss

Property-based testing explained with Hypothesis tests we ran: properties vs examples, strategies, shrinking, the real bugs it found in our…

Read article →
Databases & SQL

SQL Pagination Explained: OFFSET vs Keyset, With a Million-Row Test

SQL pagination explained with a million-row test we ran: LIMIT and OFFSET, why deep pages get slow, keyset (cursor) pagination,…

Read article →
Databases & SQL

SQL EXISTS vs IN: Differences, the NOT IN NULL Trap and Performance

SQL EXISTS vs IN explained with queries we ran: how each works, why NOT IN returns no rows when a…

Read article →
Software Development

Python Logging Explained: Levels, Handlers, Formatters and Mistakes to Avoid

Python logging explained with code we ran: levels, basicConfig quirks, named loggers, handlers and formatters, logger.exception, lazy formatting, duplicate logs…

Read article →
Software Development

JavaScript Map vs Object: Key Differences, Pitfalls and a Benchmark

JavaScript Map vs Object explained with code we ran in Node.js: key types, ordering, the prototype trap, JSON, size, WeakMap…

Read article →
Software Development

Python Type Hints Explained: Annotations, mypy and the Errors It Catches

Python type hints explained with a real mypy run: basic annotations, Optional and unions, TypedDict, Literal, Protocol, generics, Callable, strict…

Read article →
Artificial Intelligence

LLM Tokens Explained: How AI Models Read Text, Count Costs and Fill Context Windows

LLM tokens explained with a tokenizer we ran: how text is split, why counts differ by language and tokenizer, context…

Read article →
DevOps

Semantic Versioning Explained: MAJOR.MINOR.PATCH, Ranges and Release Rules

Semantic versioning explained with code we ran: MAJOR.MINOR.PATCH rules, pre-releases, correct version sorting, caret and tilde ranges, 0.x versions, lockfiles…

Read article →
DevOps

Git Reset vs Revert: How to Undo Commits Safely, With a Tested Example

Git reset vs revert explained with a repository we built: soft, mixed and hard reset, revert commits, reverting merges, recovering…

Read article →
Software Testing

The Test Pyramid Explained: Layers, Ratios and When to Break the Shape

The test pyramid explained: unit, integration and end-to-end layers, sensible ratios, the ice cream cone anti-pattern, the testing trophy and…

Read article →
Test Automation

pytest Fixtures Explained: Scope, yield Teardown, conftest.py and Factories

pytest fixtures explained with a suite we ran: how fixtures work, scopes, yield teardown, conftest.py, factory fixtures, parametrized fixtures, built-ins…

Read article →
Databases & SQL

SQL Upsert Explained: INSERT ON CONFLICT, MERGE and the REPLACE Trap

SQL upsert explained with queries we ran: INSERT ON CONFLICT DO UPDATE and DO NOTHING, the excluded row, conditional updates,…

Read article →
Databases & SQL

SQL LIKE Operator Explained: Wildcards, Case Sensitivity and Performance

The SQL LIKE operator explained with queries we ran: % and _ wildcards, escaping, NOT LIKE and NULL, case sensitivity…

Read article →
Artificial Intelligence

AI Hallucinations Explained: Why Language Models Make Things Up and How to Reduce It

AI hallucinations explained: what they are, why language models produce them, common types, how to reduce them with grounding and…

Read article →
Test Automation

Mocking in Python: unittest.mock, patch and the Mistakes That Make Tests Lie

Mocking in Python with unittest.mock, tested: Mock and return_value, side_effect, patching where a name is used, spec and autospec, mocking…

Read article →
Software Testing

Code Coverage Explained: Line vs Branch Coverage and What 100% Really Means

Code coverage explained with a real coverage.py run: line, branch and condition coverage, reading a report, why 100% coverage can…

Read article →
Artificial Intelligence

What Are AI Agents? How They Work, With a Simple Agent Loop You Can Run

What are AI agents? A plain guide to the agent loop, tools, memory and guardrails, with a small Python loop…

Read article →
Test Automation

TDD vs BDD: Key Differences, One Feature Built Both Ways and When to Use Each

TDD vs BDD explained with one discount feature built both ways: red-green-refactor with code we ran, Given-When-Then scenarios, tools, trade-offs…

Read article →
Software Testing

User Acceptance Testing (UAT): Process, Checklist and Real Examples

User acceptance testing explained: who runs it, how it differs from QA, a seven-step process, a UAT test case template,…

Read article →
Artificial Intelligence

What Are Embeddings? Vectors and Cosine Similarity Explained Simply

What are embeddings? A plain-language guide to vectors, cosine similarity and semantic search, with a small example we ran, common…

Read article →
Test Automation

Flaky Tests: What Causes Them and How to Fix Them for Good

Flaky tests explained with demos we ran: the main causes, how to confirm and diagnose one, proper fixes for timing,…

Read article →
Software Testing

Load Testing vs Stress Testing: Differences, Metrics and k6 Examples

Load testing vs stress testing explained: what each proves, spike and soak tests, the metrics that matter, why averages mislead,…

Read article →