Static type annotations in CPython

At the Core Dev sprint in Bellevue we had a brief [1] discussion about static type annotations in CPython. I want to encapsulate what was discussed and see if the wider core dev community agrees before trying to commit some of this to the devguide.

When static type annotations became a thing, the clear decision was made that they should not, for the time being, go into the standard library. There’s a separate project, typeshed, that provides type annotations for the stdlib instead. This provides more flexibility for the evolving static typing space to adjust annotations that doesn’t involve waiting for Python releases (not to mention awkward backward compatibility concerns). The recommendation to type checkers and other tooling wanting type annotations has always been to use typeshed, not annotations that are part of the stdlib.

Where we are now, though, is that we have several bits and pieces of CPython that do use type annotations, and to good effect. Build-time tools like Argument Clinic use them. The test runner, regrtest and libregrtest, uses them. The private implementation of the new REPL in 3.13 uses them. A few third-party imports into the stdlib (like tomllib, importlib.resources and importlib.metadata) use them. Also, a few type annotations have snuck into other parts of the stdlib by accident (e.g. cpython/Lib/multiprocessing/connection.py at main · python/cpython · GitHub). For the build tools that use them, as well as regrtest and PyREPL, we have CI set up so mypy tests them. I believe we don’t have it set up for importlib.resources and importlib.metadata, but those are concurrently maintained outside of our repo and tested there. tomllib had annotations when we pulled it into the stdlib, but the annotations are not currently checked by anything.

Consensus in the room (which included maintainers of typeshed and several people working on actual type checkers) seemed to be that there’s still not much to be gained from having type annotations in more of the standard library. The typeshed setup is working well, and avoids some difficult problems (e.g. type checkers supporting different syntax and features, and thus needing slightly different annotations in a forked/vendored copy of typeshed). Given that typeshed is supposed to override the stdlib, having the same annotations in the stdlib itself wouldn’t gain us anything, and having different annotations might be confusing to users.

Type annotations on the tooling we use is valuable, and we should keep it. If we pull modules into the stdlib with type annotations, we should keep them. We should take care to actually validate the type annotations, though. Right now we do that for some of the type annotated code, with mypy. Whether we should keep using mypy longer term (or switch to another type checker), and what configuration to use for mypy, is a bit uncertain. Effectively, mypy wins by default for now, but we should be open to re-evaluate. There’s a small pain point when the type-checked code uses features of the stdlib that haven’t been reflected in typeshed yet, but according to the typeshed maintainers they’re happy to take PRs for unreleased features, even if it means potentially rolling them back.

The actual type checking is currently done by CI but not from the Makefile. We should probably make it easier to run the type checker of choice during development, with a suitable make target. We should also document this state in the devguide, along with instructions on how to add new things that should be tested with the type checker.

There’s also the question of how type annotations (in particular in the stdlib) should show up in the documentation. Sphinx has the ability to display them (which we don’t currently do), but especially for complex types it might not produce the most readable output. It is probably a good idea for well-annotated libraries with simple enough types, though.

Are there any other concerns people want to bring up? (Or, for people who were at the Core Dev sprint, did I miss anything we discussed?) Do people generally agree with the status quo, or are there strong arguments to change something (like getting rid of annotations where we currently have them, or having more annotations)?

The list of action items so far, based on the consensus in the room:

  1. Clean up the type checking we currently do, making it easier to run outside of CI and to add new things to type-check.
  2. Revisit type annotations outside of the things currently being type-checked (e.g. the type annotations that snuck into a few places). Maybe we should remove them, maybe we should actively type-check them, instead. Unchecked type annotations are a bad idea.
  3. Figure out whether we should include type annotations in generated docs, and how.
  4. Document all this in the devguide.

Other thoughts on things we should improve around static type annotations in CPython?


  1. by some standards ↩︎

15 Likes

I’m generally pretty happy with the status quo, but would like to remove unchecked annotations (the multiprocessing example you link isn’t even a valid type hint!)

I’d previously written a longer post here Type annotations in the stdlib - #7 by hauntsaninja that might help provide some context to those who weren’t in the room for this consensus:

Consensus in the room […] seemed to be that there’s still not much to be gained from having type annotations in more of the standard library.

3 Likes

Memory usage and startup time.

PEP 563 and some optimizations I implemented for PEP 563 have achieved very tiny overhead. But PEP 563 is about to be deprecated.

Python is not language only for server/desktop having a lot of RAM. Python is run on Raspberry Pi, WASM, and serverless containers. We definitely should care about startup time and RAM usage.

8 Likes

Not a core dev, but as a typeshed maintainer I have some comments. I had some previous thoughts in this GitHub issue: Thoughts about type hints in the standard library · Issue #5444 · python/typeshed · GitHub and this thread, linked before by Shantanu: Type annotations in the stdlib - #22 by srittau.

Generally, I see two different goals for annotations in the standard library with different concerns: Type checking the standard library itself and providing type annotations for third party libraries and applications (replacing the stdlib part of typeshed). (Although there is a certain overlap, as often one standard library module can be considered a third party to another one.)

Personally, long term I would like to see both. A fully type checked standard library that also provides type hint to third-party software.

Type checking the standard library

This is the more realistic short-term goal and can be implemented gradually or partially: Check all code that is already annotated, and require the checks to pass in CI. Allow annotations in new or changed code, but don’t require them.

The main concern is how exactly annotated code checks calls of unannotated functions/classes. Fall back on typeshed? Just assume Any?

That’s actually the opposite of the policy we recently adopted:

We accept changes for future versions of Python after the first beta for that version was released.

That said, I’m sure we can amend the policy if this helps CPython development.

Definitely, existing annotations should be valid and should be checked!

Providing type annotations for third-party software

I agree that for the time being, typeshed seems to be the best solution. But long term – as both the type system and the annotations in typeshed mature, making changes less and less necessary, and more core developers gaining experience with typing – having the standard library annotations directly in CPython makes sense to me. This has several advantages:

  • Less effort overall required, as the overhead of opening and managing separate PRs to typeshed won’t be necessary.
  • Less chance of the type annotations diverging from the implementation.
  • Removing the need for the awkward Python version handling in both type checkers and typeshed.

Also, how should extension modules be handled? We’d either need a mixture of inline annotations for pure Python modules and type stub files for extensions modules, or we could use type stub files for all types of modules consistently. The latter would have the advantage that the modules themselves don’t need to be type checked before this could be implemented.

But these are really only questions for the future that don’t need to be answered today.

Type annotation in documentation

I believe that using valid type annotation syntax in documentation makes sense and should be quite readable. But as you mention, it’s not always the best idea to use the actual type hints, which are written with type checking in mind.

One example: If I were to document the re.Match.groups function (actual documentation here), I would annotate it like this:

Match.groups(default: str = None) -> tuple[str | None, ...]
Match.groups(default: bytes = None) -> tuple[bytes | None, ...]

This differs from the actual annotations in several ways, and is less accurate, but is much more readable than this:

    @overload
    def groups(self) -> tuple[AnyStr | Any, ...]: ...
    @overload
    def groups(self, default: _T) -> tuple[AnyStr | _T, ...]: ...

Why Any in the returned tuple? What is _T? Why the overload? What’s the meaning of AnyStr here? All of these questions have good answers that are irrelevant for documentation.

Another example would be a signature with many arguments, two of which are mutually exclusive. In type annotations, you’d use an overload to ensure that only one argument is used. But instead of duplicating the long signature, in documentation it’s much more readable to just add the sentence “x and y are mutually exclusive”.

The _typeshed pseudo-module

Another concern with type annotations in the standard library is the _typeshed pseudo-module, which is only available at type checking time. This adds many useful type aliases and protocols. When transferring types into the standard library, these types would also need to copied somehow.

2 Likes

Based on my experience maintaining downstream code I would say also that even if the decision is not to allow adding type hints in the stdlib it is still probably necessary to run a type checker in CI. No policy declaration will stop people from adding hints whether correct or incorrect: it needs to be enforced in CI somehow either that the hints must be checked or that they must not be added at all. Incorrect unchecked hints are technical debt that will eventually need to be paid by someone.

1 Like
  • +1 to improve running outside of CI using make
  • Defer to folks like Jelle on whether expanding usage makes sense. Agree that having types and not checking is not ideal.
  • Adding types to the generated docs. I believe there are few perspectives here:
    • correctness of reference which is a positive for professional development
    • learners of Python, non-CS trained users, and rapid prototyping (science): I suspect part of Python’s popularity is that it is more visually clean and understandable than other statically typed languages. As a new user, reducing the cognitive load by ignoring types in functions has been appealing to onboard learners rapidly.
    • I think if we add type information throughout the docs (which I think we should), we should do it in a way that doesn’t alienate people who don’t find types necessary. In other words, doing it right versus the most expedient solution makes sense and reducing visual clutter will be key.
  • Devguide guidance: +1

Thanks @thomas for capturing the discussion well.

P.S. I likely would have never tried Python if it weren’t for the fact that I could ignore types and have visually cleaner code. I would likely have stuck to C++/Java.

7 Likes

Our docs aren’t generated from Python source, so any annotations we wish to document would need manually adding to the RST rather than being autogenerated. An advantage of this is we can tailor and simplify them for the reader, especially as longer annotations can be complex and less readable.

4 Likes

There’s two ways in which type annotations can “serve as documentation”, and I think it’s worth distinguishing between the two:

  1. Many projects use Sphinx to generate their user-facing API docs. Sphinx calls inspect.signature() on functions and this often means that any type hints that are part of the signature show up in the generated Sphinx docs. As @hugovk says, this isn’t something we currently do for CPython’s docs, and it’s pretty unlikely we’ll switch to this any time soon.
  2. Type hints can also serve as a kind of (more informal) developer-facing documentation even if they don’t show up in user-facing docs, however: they can help readers of the source code understand how a function or class is meant to be used, improving the clarity of the code.

Both user-facing and “developer-facing” docs are important and valuable, and it’s important that both remain accurate and up-to-date. When type hints are considered as a kind of documentation, they’re much more valuable when they’re verified and checked by a type checker, regardless of the documentation cagtegory we’re considering.

1 Like

I’ve run afoul of the “annotations as (standardized) documentation” conflicting with “import performance”. In #123429, we added a new private method and included -> Self as the return type, which I included because it provides documentation to the reader of the code as to what’s the intended return type (Alex’s point 2).

Not long after, #128981 removes that documentation and its value in exchange for better import-time performance.

Many code authors (especially LLMs these days) include type annotations by default, so it’s going to become increasingly harder to avoid the introduction of these constructs in the stdlib. This case provides more evidence that we should consider Oscar’s suggestion to enforce the omission of type annotations so at least these issues can be caught earlier.

Long term, especially if we wish to allow more type annotations, we’ll probably need to find a way to performance optimize the import constructs such that using annotations (importing typing) isn’t a significant performance hit.

2 Likes

I personally don’t think that PR should’ve happened. (I left a comment as to why on the merged PR to link these threads)

Real world applications will have already imported typing so speeding up stand alone import zipfile or import pstats statements by single digit milliseconds by not having those cause the import doesn’t feel meaningful to me.

I’d rather see a focus on why typing is considered slow to import in the and ways it could be made fast for real world use cases.

5 Likes

I mean, typing isn’t used by most people for it’s runtime effects. any amount of time is slow for it, and it feels less excusable when it’s mostly possible to use the typing annotations without ever importing typing[1], and this is felt most in things that aren’t long-lived applications or are scaled on demand. I’ve got some actual timing underway and will be following up in another thread about improving import time in general once I have about a week’s worth of data (the initial start of collection was delayed).

Edit: to be clear, I don’t know that the pattern linked should be the answer, it works currently, but long term I’d love to see it “just work performantly” as people would expect for something meant to have no real effect in most cases, and not be noticeably slow enough that people feel the need to work around the import.


  1. With only a few exceptions currently, it’s possible to defer this import via a module getattr, and the import won’t ever happen unless annotations are inspected. A public example of this has been shared before here, so it is only paid for if needed. This pattern is used extensively, including in some forked dependencies at my day job. ↩︎

7 Likes

I think the annotation in @picnixz’s PR should not have been added. There’s consensus that we’re not adding annotations to Lib/. We do annotate some of our tools, and those annotations are checked in CI. Annotations that are not checked by CI should not be added to any project, IMO.

7 Likes

This consensus is a bit too hard line absolutest for me even though I agree with your closing opinion. That’s the problem that needs solving.

We need to get to the point where annotations in the stdlib are checked in our dev workflow and CI and a standard for which forms of annotations we allow vs not with specific reasons why.

I’m probably responsible for the errant-looking annotations within multiprocessing they helped understand the code. If we had checking within the stdlib, them being invalid from a type analyzer POV wouldn’t’ve happened.

We also have some stdlib modules that are semi-maintained externally for updatable third party use w/o upgrading Python itself; with code synced to the stdlib and stdlib changes synced to the external project. zipfile happens one of those IIUC? It ultimately makes developer life difficult if we require stripping of annotations when doing such syncs, lowers the maintainability of code, and dissuades contributions.

I’m not saying YOLO annotate everything in Lib/, just that a policy of blocking all annotations isn’t a healthy one for us to take in the long run. Type checkers themselves don’t use stdlib source annotations, they use typeshed. We need checked stdlib annotations for our own internal use. How do we get there?

5 Likes

Unit tests.

And some model for annotations that matches how we handle types in the stdlib (i.e. duck typing), ideally without heavyweight runtime classes, rather than imposing a different design philosophy (i.e. nominal typing).

But mostly unit tests.

2 Likes

I think the first thing we need to do is reduce or remove the import cost of typing. It’s well known that there is a significant runtime cost to importing typing, and I don’t think it’s acceptable to impose that cost on all Python programs using certain stdlib modules, purely for something that is for our own internal use, and which is for static checking with no runtime effect.

I’m pretty hardline on the philosophy of “you shouldn’t pay for what you don’t use”, but I know others are less so[1]. However, I think we should be stricter with the stdlib, where the cost is paid by a significant proportion of Python programs, many of which don’t use typing, and some of which are performance-critical.

Apart from the matter of import cost, I’m neutral on the use of type annotations. I agree that if we use them, we should check that they are accurate. I think we should only use simple annotations (some of the mindblowing generic type stuff obfuscates code rather than clarifying it). And I think we should make a deliberate change to the current policy, rather than just allowing violations to happen on an adhoc basis. But those are (IMO) minor points.


  1. static checks that need import typing being a clear example of people not agreeing with that stance ↩︎

10 Likes

I agree, but the tricky part is the import cost is distributed across every module that uses it. Why should I have to instantiate and evaluate so many objects for annotations at runtime?

We’ve struggled to get lazily evaluated annotations integrated, and even then it looks like they’ll be just as expensive at the end of it. It’s a shame we didn’t immediately turn them into string literals back when we turned them into type hints, but that’s long past now.

It’s not just typing; it’s every annotation that’s more complex than a single Py_INCREF.

1 Like

(It’s unfortunately gone up again in 3.14, but in 3.13 it’s actually lower than several other popular stdlib modules such as dataclasses and inspect, FWIW. The problem on 3.13 is much less severe than it used to be.)

3 Likes