Modern Type Syntax

Since Python 3.9 and 3.10, built-in collections and union types can be hinted directly — list[int], dict[str, int], X | Y — without importing anything from typing.

Early versions of the typing module required importing parallel generic aliases (List, Dict, Union, ...) because built-in types like list and dict couldn't be subscripted directly. PEP 585 (3.9) and PEP 604 (3.10) removed that requirement: built-in collection types now support subscription directly, and unions can be written with the | operator. This is the preferred style for new code targeting modern Python.

Built-in Generics: list[int], dict[str, int]

As of Python 3.9 (PEP 585), the built-in collection types — list, dict, set, tuple, frozenset, type, and others — support subscript syntax directly for type hints, meaning list[int], dict[str, int], set[str], tuple[int, str] are all valid without importing anything.

list[str] and dict[str, int] are ordinary built-in types being subscripted; no from typing import List, Dict is needed, unlike in versions before 3.9.

def total_length(names: list[str]) -> int:
    return sum(len(name) for name in names)

def count_words(text: str) -> dict[str, int]:
    counts: dict[str, int] = {}
    for word in text.split():
        counts[word] = counts.get(word, 0) + 1
    return counts

print(total_length(["ab", "cde"]))    # 5
print(count_words("a b a c b a"))     # {'a': 3, 'b': 2, 'c': 1}

Old Style vs New Style, Side by Side

Both are understood identically by type checkers; List[int] and list[int] are equivalent, but the new style needs no import and matches the runtime type name exactly.

typing.List, typing.Dict, typing.Tuple, typing.Set, and similar aliases still work (for backward compatibility and to support code targeting older Python), but are considered legacy for code that only needs to run on 3.9+.

# Old style (typing generic aliases — still valid, but legacy for new code)
from typing import List, Dict, Tuple, Set

def old_style(items: List[int]) -> Dict[str, int]:
    ...

# New style (3.9+, preferred)
def new_style(items: list[int]) -> dict[str, int]:
    ...

Nested Generics

Built-in generics compose naturally, just like the old typing aliases did — a list of dicts, a dict of lists, tuples of specific fixed shapes, and so on.

dict[int, list[str]] reads naturally as 'a dict mapping int keys to lists of strings' — nesting works exactly as the syntax visually suggests.

def group_by_length(words: list[str]) -> dict[int, list[str]]:
    groups: dict[int, list[str]] = {}
    for word in words:
        groups.setdefault(len(word), []).append(word)
    return groups

print(group_by_length(["a", "bb", "cc", "ddd"]))
# {1: ['a'], 2: ['bb', 'cc'], 3: ['ddd']}

Union Types with the | Operator

As of Python 3.10 (PEP 604), X | Y can be used directly as a type hint to mean 'X or Y', replacing typing.Union[X, Y] in new code. This works both in annotations and, since 3.10, as an actual runtime-constructible types.UnionType object.

int | float | str reads left to right as 'int or float or str' — equivalent to Union[int, float, str] but without the typing import.

def stringify(value: int | float | str) -> str:
    return str(value)

print(stringify(42))     # '42'
print(stringify(3.14))   # '3.14'

Optional as X | None

typing.Optional[X] has a direct modern equivalent: X | None. This is by far the most common use of the | union syntax and is the preferred style for new code.

str | None reads as 'a str, or None' — functionally and semantically identical to Optional[str], just without importing Optional.

def find_user(user_id: int, users: dict[int, str]) -> str | None:
    return users.get(user_id)

result = find_user(5, {1: "Ana"})
if result is not None:
    print(result.upper())

Version Context: What Requires What

These features are version-gated and matter when targeting older interpreters or libraries that must support them. Built-in generic subscripting (list[int]) requires 3.9+. The | union operator in annotations requires 3.10+ to use directly in code that RUNS the annotation (though from __future__ import annotations, 3.7+, can make | usable in annotations even on 3.7-3.9 since the annotation is then never actually evaluated at runtime, only read as a string by tools).

The 'workaround' column is what you'd use to write the same meaning on an older Python version, either via the typing module aliases or by deferring annotation evaluation with the __future__ import.

Feature                          Minimum version (direct use)   Workaround for older versions
list[int], dict[str, int]        3.9                             typing.List[int], typing.Dict[str, int]
X | Y as a type hint              3.10                            typing.Union[X, Y], or `from __future__ import annotations`
X | None                          3.10                            typing.Optional[X]

Using | at Runtime, Not Just in Annotations

Since X | Y is real syntax (not just special-cased inside annotations), it can also be used to build a union type value directly, e.g. for isinstance checks (3.10+ ; but note isinstance also accepts a tuple of types on all versions, which remains the more portable idiom).

int | float constructs an actual types.UnionType object usable with isinstance on 3.10+; the tuple form (int, float) has always worked and remains the version-agnostic choice.

value = 42

if isinstance(value, int | float):   # 3.10+
    print("numeric")

# Equivalent and portable to all versions:
if isinstance(value, (int, float)):
    print("numeric")

Practical Usage: What to Write Today

For code targeting Python 3.10+, prefer built-in generics (list[int]) and | unions (X | None) throughout — they're shorter, need no typing imports for these cases, and match how the runtime types themselves are named. Reserve typing imports for constructs that have no built-in equivalent (Any, Literal, Callable, TypeVar, Generic, and others covered in typing Fundamentals) or when supporting older Python versions.

Key Takeaways

PEP 585 (3.9+) lets built-in types (list, dict, set, tuple, ...) be subscripted directly for hints — no typing.List/Dict import needed.

PEP 604 (3.10+) lets X | Y express a union directly, replacing typing.Union[X, Y] in new code.

X | None is the modern equivalent of typing.Optional[X].

The old typing aliases (List, Dict, Union, Optional, ...) still work everywhere for backward compatibility; they're legacy, not deprecated-and-broken.

from __future__ import annotations can make newer syntax like | safe to write even when targeting slightly older interpreters, since annotations are then never evaluated at runtime.

isinstance() accepts either a tuple of types (all versions) or, since 3.10, an X | Y union object directly.

def process(items: list[int]) -> None:... process([1, 2, 3]) # fine on 3.9+, but SyntaxError-free even on 3.7 IF only run as annotation... actually fails to import/run on <3.9 without __future__ from __future__ import annotations def process(items: list[int]) -> None: # safe on 3.7+ since annotations are deferred/stringified...

Subscripting built-in list at runtime for annotations requires 3.9+ directly; on older versions targeted for compatibility, from __future__ import annotations defers evaluation so the annotation is never actually executed, avoiding the error.

def parse(value: str) -> int | None: # used directly on a 3.8 interpreter without __future__ import return int(value) if value.isdigit() else None from typing import Optional, Union # or add from __future__ import annotations and use int | None def parse(value: str) -> Optional[int]: return int(value) if value.isdigit() else None

int | None as a live-evaluated annotation requires 3.10+; targeting 3.8 directly requires either typing.Optional/Union or deferring evaluation with from __future__ import annotations.

from typing import List import typing def combine(a: List[int], b: list[int]) -> list: # inconsistent style, mixes old and new for no reason return a + b def combine(a: list[int], b: list[int]) -> list[int]: # consistent modern style throughout return a + b

Mixing typing.List and built-in list in the same codebase (or even the same signature) without a version-compatibility reason is just inconsistent; pick the modern built-in style uniformly for 3.9+ targets.

What Python version introduced the ability to subscript built-in types like list and dict directly for type hints?

Python 3.9, via PEP 585.

What is the modern equivalent, introduced in Python 3.10, of typing.Union[int, str]?

int | str, via the PEP 604 union operator syntax used directly in type hints.

What is the modern equivalent of typing.Optional[str]?

str | None, since Optional[X] is defined as Union[X, None] and | is the modern spelling of Union.

Does using typing.List and typing.Dict in new code targeting 3.9+ still work, or has it been removed?

It still works — the typing aliases remain valid for backward compatibility and are not deprecated-to-the-point-of-removal; they're simply considered legacy style when the built-in subscriptable versions are available.

How can from __future__ import annotations allow use of X | Y syntax in annotations even when targeting a Python version older than 3.10?

It makes every annotation in the module be stored as an unevaluated string rather than executed as real code at definition time, so syntax that would otherwise need runtime support to evaluate (like a live | union object) is never actually executed by the interpreter — only read later by tools that parse the string.

What are the two valid ways to write an isinstance check against multiple types, and which is more portable across Python versions?

isinstance(x, (int, float)) using a tuple of types (works on every Python version) or isinstance(x, int | float) using the union operator (requires 3.10+); the tuple form is the more portable choice.

Why might a nested hint like dict[str, list[int]] be written entirely with built-in generics rather than mixing in typing.Dict or typing.List?

For consistency and simplicity: on 3.9+, built-in generics compose the same way the legacy typing aliases did, so there's no functional reason to import typing.Dict/List — mixing the two styles adds unnecessary imports and inconsistency without any added capability.

Write a type-hinted function merge_counts(a: dict[str, int], b: dict[str, int]) -> dict[str, int] that sums counts for shared keys.

def merge_counts(a: dict[str, int], b: dict[str, int]) -> dict[str, int]: result = dict(a) for key, value in b.items(): result[key] = result.get(key, 0) + value return result print(merge_counts({"x": 1, "y": 2}, {"y": 3, "z": 4})) # {'x': 1, 'y': 5, 'z': 4}

All three dict hints use the built-in dict[str, int] syntax directly (3.9+), with no typing.Dict import needed.

What does this print?

got int: 5 got str: hi nothing The union int | str | None matches all three call values, and the function branches on whether the value is None before formatting a message for the other two cases.

Rewrite this legacy-style signature using modern (3.10+) syntax: def lookup(table: Dict[str, List[int]], key: str) -> Optional[List[int]]: def lookup(table: dict[str, list[int]], key: str) -> list[int] | None: return table.get(key) print(lookup({"a": [1, 2]}, "a")) # [1, 2] print(lookup({"a": [1, 2]}, "b")) # None

Dict[str, List[int]] becomes dict[str, list[int]] (PEP 585), and Optional[List[int]] becomes list[int] | None (PEP 604's Optional equivalent) — same meaning, no typing import required.

Does this run successfully, and roughly what does it print?

Yes, it runs and prints approximately 12.56636. from __future__ import annotations defers all annotation evaluation to strings, so Circle | Square (and the redundant explicit quotes around it) never needs to be evaluated as live code at definition time, even though Circle and Square aren't yet defined when the area() signature is parsed.

def describe(value: int | str | None) -> str:
    if value is None:
        return "nothing"
    return f"got {type(value).__name__}: {value}"

print(describe(5))
print(describe("hi"))
print(describe(None))
from __future__ import annotations

def area(shape: "Circle | Square") -> float:
    return shape.area()

class Circle:
    def __init__(self, r): self.r = r
    def area(self): return 3.14159 * self.r ** 2

class Square:
    def __init__(self, s): self.s = s
    def area(self): return self.s ** 2

print(area(Circle(2)))