Two changes cover almost all of it. PEP 585 (Python 3.9+) lets you subscript builtins, so typing.List[int] becomes list[int] and you drop the import. PEP 604 (Python 3.10+) adds the | operator, so Optional[X] becomes X | None and Union[A, B] becomes A | B. These are equivalent — I verified Optional[int] == Union[int, None] == (int | None) all return True. Do it in one paste with the Type Hint Modernizer, or with pyupgrade/Ruff's UP rules in your pipeline.
Older Python code is full of from typing import List, Dict, Optional, Union and annotations like Optional[Dict[str, int]]. Modern Python (3.9+/3.10+) lets you write the same types more cleanly — dict[str, int] | None — with no typing imports at all. Linters now actively push this style, so modernizing keeps your code current and quiets the warnings.
Here's exactly what changes, proof the new forms are equivalent, and the one compatibility gotcha to watch. Every claim below was checked on Python 3.14.
PEP 585 — subscript the builtins directly
Since Python 3.9 you can parameterize the builtin collection types, so you no longer need their typing aliases:
from typing import List, Dict, Tuple # no longer neededdef f(x: List[int], y: Dict[str, int]) -> Tuple[int, ...]: ...# becomes:def f(x: list[int], y: dict[str, int]) -> tuple[int, ...]: ...
Honest nuance: list[int] and typing.List[int] are not the same object at runtime — I checked, and list[int] == List[int] returns False (they're different classes). But type checkers treat them identically, which is all that matters for annotations. The builtin form is just the modern spelling.
PEP 604 — the | union operator
Since Python 3.10, | builds union types, which replaces both Optional and Union:
from typing import Optional, Uniona: Optional[int]b: Union[str, bytes]# becomes:a: int | Noneb: str | bytes
These aren't just visually similar — they're the *same type*. Optional[X] is defined as Union[X, None], and the | form produces an equal union. Verified on Python 3.14:
from typing import Optional, UnionOptional[int] == Union[int, None] # True(int | None) == Optional[int] # True# get_args(int | None) -> (int, NoneType)
Do it automatically (tested)
You don't have to hand-edit. The free Type Hint Modernizer applies both PEPs in one paste — including nested generics — and cleans your typing imports. Here's a real before/after it produces:
from typing import List, Dict, Optional, Uniondef load(ids: List[int],opts: Optional[Dict[str, int]] = None) -> Union[str, bytes]:...
def load(ids: list[int],opts: dict[str, int] | None = None) -> str | bytes:...
It even handles nesting: typing.List[typing.Optional[int]] becomes list[int | None]. For a whole codebase, the same transformation is built into pyupgrade (--py310-plus) and Ruff's UP006/UP007/UP045 rules — run those in CI to keep new code modern automatically.
The one caveat: runtime version
The modern syntaxes need a modern interpreter at runtime, not just a modern type checker: list[int] needs Python 3.9+, and X | None needs 3.10+. If you evaluate annotations at runtime (e.g. Pydantic, dataclasses on older Python), the new syntax can raise TypeError on 3.8/3.9.
The escape hatch: add from __future__ import annotations at the top of the file. That makes all annotations strings (evaluated lazily), so the modern syntax is accepted even on older runtimes. Only modernize code you're sure runs on 3.9/3.10+ — or add that import.
Whichever way you modernize — the tool, pyupgrade, or by hand — review the diff before committing. It's a targeted rewrite, and you want to eyeball anything with unusual formatting or annotations hidden in strings.
Quick reference
| Old (typing) | Modern | Since |
|---|---|---|
List[int] | list[int] | Py 3.9 (PEP 585) |
Dict[str, int] | dict[str, int] | Py 3.9 |
Tuple[int, ...] | tuple[int, ...] | Py 3.9 |
Set[str] / Type[X] | set[str] / type[X] | Py 3.9 |
Optional[X] | X | None | Py 3.10 (PEP 604) |
Union[A, B] | A | B | Py 3.10 |
Modernize your type hints — free
Paste Python with old typing syntax and get the modern PEP 585/604 form back instantly. In your browser, nothing uploaded.
Open the Type Hint ModernizerFree tools mentioned here
Related guides
Frequently asked questions
How do I convert Optional[X] to X | None?
Optional[X] is defined as Union[X, None], and in Python 3.10+ (PEP 604) that's written X | None — they're the same type (Optional[int] == Union[int, None] == int | None all evaluate True). Rewrite each Optional[...] as ... | None; the online Type Hint Modernizer does it automatically, including Union[A, B] → A | B.
Is typing.List the same as list in Python?
As an annotation, yes — type checkers treat typing.List[int] and list[int] identically. At runtime they're different objects (list[int] == List[int] is actually False), but since PEP 585 (Python 3.9+) the builtin form is the recommended spelling and lets you drop the typing import.
Do modern type hints work on older Python?
The builtin-generic form (list[int]) needs Python 3.9+ and the X | None union needs 3.10+ at runtime. If annotations are evaluated at runtime on an older version you may get a TypeError; add 'from __future__ import annotations' to defer evaluation and the modern syntax works as annotations on earlier interpreters too.
What tool modernizes Python type hints automatically?
pyupgrade (with --py39-plus/--py310-plus) and Ruff (rules UP006, UP007, UP045) make these changes across a whole project in CI. For a quick one-off or a single file, an online Type Hint Modernizer applies PEP 585 and PEP 604 in one paste with nothing to install.
Why modernize type hints at all?
The modern syntax is shorter, more readable, and removes a layer of typing imports, and current linters flag the old forms — so modernizing keeps your codebase consistent with today's standards. It's purely a style/clarity upgrade; the meaning of the types is identical.