Right, so you’ve got your code nicely typed, you run mypy, and it passes. Then you try to import a third-party library and… boom. A cascade of errors. The library has no type hints. Your brilliant, statically-verified masterpiece is now festooned with Any annotations and your type checker is giving you the silent treatment. This is where type stubs and the Typeshed repository come in—they’re the duct tape and baling wire that hold the Python type ecosystem together, and you need to understand them.

Think of a type stub file (.pyi) as a sort of “type header” for a module. It has the same name as the module (e.g., requests.pyi for the requests module) but it only contains definitions of public classes, functions, and variables, with their type annotations. The actual implementation? Gone. It’s just the signatures, the promises the code makes, without any of the messy logic to back it up.

You use these stubs to teach your type checker about libraries that are either untyped or, just as commonly, have type hints that are… let’s call them “aspirational” rather than accurate. The stub lets you override the library’s own (often incomplete) hints with your own, better ones.

How to Write a Basic Stub File

Let’s say you have a poorly-typed utility library, shaky_math.py:

# shaky_math.py
def multiply(x, y=2):
    """Returns x * y. Sometimes."""
    return x * y

Trying to use this with type checking is a nightmare. x and y are both Any. So, you create a shaky_math.pyi file in the same directory or in a designated stubs folder:

# shaky_math.pyi
def multiply(x: int, y: int = 2) -> int: ...

See that ...? It’s not just a placeholder; it’s the required syntax. It signifies “this is a stub, the body exists elsewhere.” Now, when your type checker looks at shaky_math, it will see your .pyi file and use those precise int types instead of the terrifying void of Any.

The Glorious Typeshed Repository

Now, imagine if everyone had to write stubs for every library they used. It would be chaos. This is where Typeshed comes in. It’s a massive, communal repository of type stubs for the Python standard library and a ton of popular third-party packages (requests, django, flask, you name it).

When you install a type checker like mypy or pyright, it automatically bundles a copy of Typeshed. So when you import json, your type checker isn’t guessing; it’s pulling the meticulously curated stubs for the json module from Typeshed. This is why your type checker knows that json.load() returns Any (a controversial but honest choice) but json.loads(some string) also returns Any (a less honest one, but we’ll get to that).

The Limits of Stubs and the Any Escape Hatch

Stubs are a declaration of intent, not a runtime enforcement mechanism. This is a crucial distinction. If the actual function returns a string but your stub says it returns an integer, the type checker will happily believe you and then your code will explode at runtime. The stub is a lie you tell your type checker, and you are responsible for making that lie convincing.

This is also why Typeshed stubs for third-party libraries can sometimes be out of sync with the actual library. The library updates its API in a new version, but the stub in Typeshed hasn’t been updated yet. When this happens, you’ll see baffling errors about missing attributes or wrong argument counts. The solution is often to check the library’s issue tracker or, if you’re feeling brave, contribute a fix back to Typeshed.

Overriding Typeshed and Handling Conflicts

Sometimes, the Typeshed maintainers make a choice you disagree with. A classic example is json.loads. By default, it returns Any, because the JSON could be literally anything. This is technically correct but practically useless. If you know you’re getting a list[str] back from a particular API call, you’re stuck manually casting it every time: data = typing.cast(list[str], json.loads(response)).

You can’t just edit the bundled Typeshed file; that’s a maintenance nightmare. Instead, you can create your own local stub to override it. Place a json.pyi file in your project’s stubs directory and tell your type checker where to find it (e.g., with mypy --custom-typeshed-dir /path/to/your/stubs). Your stub could look like this:

# /path/to/your/stubs/json.pyi
from typing import Any
def loads(s: str, *, parse_float: Any = ..., parse_int: Any = ..., parse_constant: Any = ..., object_hook: Any = ..., object_pairs_hook: Any = ..., **kwargs: Any) -> Any: ...  # The original, meh signature

# Your more useful overload
@overload
def loads(s: str) -> list[str]: ...

This is an advanced move, but it demonstrates the power of the system: you can effectively fork the official type definitions to better match how you’re actually using the code.

Best Practices and Pitfalls

  1. # type: ignore is a Last Resort: Before slapping a # type: ignore on an import error for a missing stub, see if stubs exist separately. Many libraries offer them as a package-name-stubs on PyPI (e.g., django-stubs). Install that first.
  2. Stubs are for Interfaces, Not Implementation: Don’t get clever in a .pyi file. No logic, no default values beyond simple literals. Its only job is to define types.
  3. The __init__.pyi Trick: To stub a package’s __init__.py, you create an __init__.pyi file. This is often where module-level constants and the public API are declared.
  4. Version Awareness: Be aware of the version of the library your stubs are for. Typeshed stubs often use if sys.version_info >= (3, 10): blocks to provide different types for different Python versions.

Type stubs are the unsung heroes of Python’s static typing story. They’re the reason you can use the entire ecosystem of Python packages without your type checker having a complete meltdown. Are they a slightly janky solution? Absolutely. But they work, and understanding how to use them—and how to write your own—is what separates a novice from a true type-wrangling professional.