Skip to content

modernize-annotations

modernize-annotations rewrites a legacy typing annotation to the spelling the language now supports, so List[int] reads list[int] and Optional[int] reads int | None, and it removes the typing import a module kept only to reach the old spellings. PEP 585 gave the typing generics their builtin form on Python 3.9, and PEP 604 gave the unions their | form on 3.10.

Each rewrite runs behind its own facet and its own version floor, so a project on 3.9 converts its generics while its unions stay as written until 3.10. A project with no target-version set keeps both spellings, since an unset field meets neither version floor.

Dropping the Import

The rewrite leaves a typing import unread once it has removed every read of that name, and the rule removes that import in the same pass, one name at a time rather than one line at a time:

from typing import Optional, cast feeds value: Optional[int] and the cast(object, value) call in widen. Optional[int] rewrites to int | None and only Optional drops from the import, which survives as from typing import cast, because the call still reads cast at runtime.

from typing import cast


def widen(value: int | None) -> object:
    return cast(object, value)
python

A read the rewrite could not remove keeps the import in place, so a suppressed line or a forward reference the rule left alone keeps it. The Suppression chapter covers the directives.

Both facets run, so an annotation carrying both legacy spellings settles in one pass.

Below 3.10 only rewrite-generics runs, since X | Y raises at runtime before the PEP 604 form arrives, and below 3.9 neither one does.

Configuration

KeyTypeDefaultMeaning
enabledbooltrueTurns the rule on or off.
rewrite-genericsbooltrueConverts a typing generic to the builtin PEP 585 gave it, so List[int] reads as list[int]. Runs on target-version 3.9 and higher, and false leaves every typing generic in place.
rewrite-unionsbooltrueRewrites Optional[X] and Union[X, Y] to the PEP 604 X | None and X | Y forms. Runs on target-version 3.10 and higher, and false leaves every legacy union in place.

The target-version field from the top-level Configuration gates each facet per project, and an unset field keeps both legacy spellings as written.

Facets

Both facets find their target through whatever name the module bound, so a bare Optional, a module-qualified typing.Optional, an aliased Optional as Opt, and the typing_extensions spelling of any of them all take the same rewrite.

rewrite-generics

rewrite-generics converts the typing generics whose PEP 585 replacement is a builtin, covering Dict, FrozenSet, List, Set, Tuple, and Type, with or without a subscript. A generic whose replacement lives under collections instead (Deque, DefaultDict) stays as written, because the rewrite would need an import this rule never adds.

rewrite-unions

rewrite-unions joins the members of an Optional or a Union with |, appending the | None member an Optional implies. A member that cannot take the operator keeps the whole annotation in its legacy form, which covers a forward-reference string such as Optional["Node"], where the rewritten "Node" | None would raise when the annotation is evaluated. An annotation with a comment inside its subscript stays as written too, because the rewrite rebuilds the expression and would drop the comment.

The Canonical Case

x: typing.Optional[typing.List[int]] stacks both legacy spellings, with typing.List[int] inside typing.Optional[...].

Both rewrite together in one pass, the inner generic becoming list[int] and the Optional wrapper becoming the PEP 604 | None, so x ends up as list[int] | None. Nothing reads typing once those rewrites are written, so import typing is removed as well.


x: list[int] | None = None
python

More Examples

label = f"{Optional[int]}" carries Optional[int] inside an f-string replacement field, and plain: Optional[str] sits below it. Both rewrite, the field to {int | None} and the annotation to str | None, because the rule reaches expressions inside replacement fields too, and with both reads gone the Optional import is removed.

With target-version = "3.9", sizes annotates its parameters with List, Dict, Set, Tuple, FrozenSet, and Type.

Every annotation converts in place to list, dict, set, tuple, frozenset, and type, because PEP 585 gave each of these generics a builtin spelling on Python 3.9. Once the last one converts nothing reads the typing import, so the whole line is removed.

from typing import Optional, cast feeds value: Optional[int] and the cast(object, value) call in widen. Optional[int] rewrites to int | None and only Optional drops from the import, which survives as from typing import cast, because the call still reads cast at runtime.

With target-version = "3.10", x: Optional[int] sits under from typing import Optional. The annotation rewrites to x: int | None, because the | union spelling is available from 3.10, and with that only read of Optional gone the import line is removed with it.

With rewrite-generics = false and target-version = "3.14", load takes names: List[str] and limit: Optional[int]. names: List[str] keeps its spelling and its List import, whereas limit's Optional[int] becomes int | None and the unread Optional import is removed, because turning off the generic facet leaves the union facet running.

With rewrite-unions = false and target-version = "3.14", load takes names: List[str] and limit: Optional[int]. limit: Optional[int] keeps its spelling and its Optional import, whereas names: List[str] becomes list[str] and the unread List import is dropped, because turning off the union facet leaves the generic facet running.

With target-version = "3.9", load takes names: List[str] and limit: Optional[int]. names converts to list[str] and limit stays as written, because builtin generics are subscriptable from Python 3.9 whereas the | union is valid at runtime only from 3.10, so the typing import shrinks to Optional, the one name still read.

With target-version = "3.10", x: Union[int, str] sits under from typing import Union. The annotation rewrites to x: int | str, each member joined with the PEP 604 | in the order the subscript listed them, and with that only read of Union gone its import is removed too.

No Change

parent: Optional["Node"] wraps the forward-reference string "Node". The whole annotation stays in its legacy form, because the modern spelling "Node" | None raises when the annotation is evaluated, since str defines no | operator.

No Change

With target-version = "3.9", queue: Deque[int] names a typing generic whose modern spelling is collections.deque rather than a builtin. The annotation stays as written and its Deque import with it, because the rewrite would need an import this file does not have and the rule never adds one.

No Change

No target-version is set for this case, and x: Optional[int] sits under its Optional import. Both pass through untouched, because each rewrite runs only when the target sits at or above its version floor, and an unset field clears neither floor.

  1. PRIMARY overflows its line with values written in the older Optional and Union forms, and target-version = "3.10" allows the | syntax. modernize-annotations rewrites each value to | form first, so every rule downstream measures the shorter text, and the dict then explodes, its keys sort so "delta_long" moves ahead of "gamma", and align-colons pads the : column against the rewritten widths. With nothing left reading either name, from typing import Optional, Union is removed.

For the gate semantics, target-version in the Configuration chapter covers how the field is read across version-gated rules.