> ## Content Index
> Fetch the complete content index at: https://sudosecurity.org/llms.txt
> Use this file to discover other available public pages before exploring further.

# Python's frozendict Is Finally Happening — And It's More Important Than You Think
- URL: https://sudosecurity.org/pythons-frozendict-is-finally-happening-and-its-more-important-than-you-think/
- Published: 2026-04-08T22:11:00.000Z
- Updated: 2026-07-17T10:37:42.000Z
- Description: Python 3.15 ships a built-in frozendict — hashable, O(1) lookups, order-preserving, thread-safe by design. Here's the full technical picture: the 14-year history, how hashing works, what it unlocks for lru_cache and free-threaded Python, and every sharp edge you need to know before you use it.
- Author: Traven
- Tags: Programming, Programming Languages, Python, Software Development

Python's had this asymmetry for years and most developers just accepted it as a law of nature. You want a constant string? Done. Constant integer? Obvious. Constant tuple? Yep. Constant frozenset? Got it. Constant dict? Sorry, no. You get `types.MappingProxyType` if you want a read-only view, except it's not hashable, you can't subclass it, and you can retrieve the underlying mutable dict through `gc.get_referents()` if you're sufficiently motivated to undo your own protection.

PEP 814, authored by Victor Stinner and Donghee Na, was accepted by the Python Steering Council on February 11, 2026\. Python 3.15 ships a `frozendict` as a built-in. No import. No third-party package. It's in `builtins` alongside `dict`, `frozenset`, `list`, and everything else you take for granted.

This took fourteen years of failed proposals to land. Three separate PEPs. Two rejections. One deferral. The idea was good the whole time. The timing was wrong. Now it isn't.

## The Fourteen-Year Road to Getting Here

Stinner first proposed this in 2012 as PEP 416\. Same title: "Add a frozendict builtin type." The Steering Council rejected it. Raymond Hettinger argued that demand was low, that existing idioms handled the use cases, that multiple threads could "just agree by convention" not to mutate a shared dict. He was wrong about the demand. He was right that convention was what people were using, but that's not a defense of the status quo.

Then PEP 603 arrived in 2019\. Yury Selivanov proposed `collections.frozenmap` instead, using a Hash Array Mapped Trie (HAMT) data structure. The HAMT gave you O(1) amortized mutation operations for producing modified copies, which is useful in functional programming patterns. The cost: O(log n) lookup instead of O(1). Also unordered. Also in `collections` rather than builtins. It was rejected too, though the HAMT code it would have exposed was already in CPython powering the `contextvars` module. The code existed. The type just wasn't public.

PEP 814 is different from both because the Python ecosystem moved in a specific direction between 2012 and 2025\. Three things changed:

1. `asyncio` landed in 2014 and people started writing concurrent Python seriously.
2. Free threading shipped in Python 3.13 in 2024\. The GIL is now optional.
3. `concurrent.interpreters` arrived in Python 3.14 in 2025, enabling true parallel subinterpreters.

Every one of these additions created demand for immutable shared state. When you remove the GIL, mutable shared state between threads becomes genuinely dangerous in ways it wasn't when the GIL serialized everything. An immutable dict you can pass across thread boundaries without a lock is no longer a nice-to-have. It's the safe option.

The Python community figured this out. Stinner and Na submitted PEP 814 in November 2025\. The Steering Council accepted it February 11, 2026\. Implementation PR is at `github.com/python/cpython/pull/141508`. The type lands before the Python 3.15 beta freeze on May 5, 2026.

## What frozendict Is, Precisely

`frozendict` is a built-in immutable dictionary type. It is not a subclass of `dict`. It inherits directly from `object`. This design decision matters and was deliberate — if `frozendict` inherited from `dict`, you could call `dict.__setitem__(frozendict_instance, key, value)` and bypass immutability by going through the parent class. The PEP rejected inheritance for exactly this reason.

At the C level, the implementation adds `PyFrozenDictObject`, a struct that inherits from `PyDictObject` and adds one field: `ma_hash`. The hash gets computed on first call and cached in that field. Most of the dict implementation reuses verbatim. The cost of the type is minimal.

### Constructing One

```python
# Four construction patterns from PEP 814
 
# Empty frozendict
empty = frozendict()
 
# From keyword arguments
config = frozendict(host="localhost", port=8080, debug=False)
 
# From an existing dict
existing = {"theme": "dark", "lang": "en"}
fd = frozendict(existing)
 
# From an iterable of (key, value) pairs
from_pairs = frozendict([("a", 1), ("b", 2)])
 
# Combined: collection + kwargs
combined = frozendict({"host": "localhost"}, port=8080)

```

`frozendict(host="localhost", port=8080, debug=False)` builds the frozendict from the keyword arguments. Keys are the argument names as strings, values are the passed values. Identical to `dict()` construction.

`frozendict(existing)` performs a shallow copy of `existing`. The O(n) cost is the copy — each key-value pair gets copied from the source dict into the new frozendict's internal hash table. The original dict's memory layout is not modified.

`frozendict([("a", 1), ("b", 2)])` iterates the sequence of tuples and inserts each pair. Same as calling `dict()` with the same argument.

### Reading Works Exactly Like dict

```python
config = frozendict(host="localhost", port=8080, debug=False)
 
# Standard key access
host = config["host"]              # "localhost"
 
# .get() with default
port = config.get("port", 3000)   # 8080
 
# Membership check
has_debug = "debug" in config     # True
missing    = "timeout" not in config  # True
 
# Iteration
for key in config:
    print(key)                    # host, port, debug (insertion order)
 
for key, value in config.items():
    print(f"{key}: {value}")
 
# Length
n = len(config)                   # 3

```

`config["host"]` uses the same O(1) hash table lookup as a regular dict. The internal hash table is identical in structure to a standard Python dict hash table. There is no performance difference for reads.

`config.get("port", 3000)` returns `config["port"]` if the key exists, `3000` if it doesn't. No exception raised.

`for key in config` iterates in insertion order. `frozendict` preserves insertion order the same way `dict` has since Python 3.7.

### Mutations Raise Immediately

```python
config = frozendict(host="localhost", port=8080)
 
# Every mutation attempt raises TypeError
config["host"] = "0.0.0.0"    # TypeError: 'frozendict' object does not support item assignment
config["timeout"] = 30         # TypeError: 'frozendict' object does not support item assignment
del config["port"]             # TypeError: 'frozendict' object doesn't support item deletion
 
# Methods that mutate dict don't exist on frozendict
# config.pop("host")         # AttributeError: 'frozendict' object has no attribute 'pop'
# config.clear()             # AttributeError: 'frozendict' object has no attribute 'clear'
# config.update({"x": 1})   # AttributeError: 'frozendict' object has no attribute 'update'
# config.setdefault("x", 1) # AttributeError: 'frozendict' object has no attribute 'setdefault'

```

`config["host"] = "0.0.0.0"` raises `TypeError` immediately. The `frozendict` type overrides `__setitem__` to raise rather than mutate. Same for `__delitem__`.

The mutation methods (`pop`, `clear`, `update`, `setdefault`, `popitem`) do not exist on `frozendict` at all. Calling them raises `AttributeError`, not `TypeError`. There is no method to call that could accidentally mutate the data.

## Hashing: The Whole Point

This is the property that separates `frozendict` from `dict` and from `types.MappingProxyType`.

```python
config = frozendict(host="localhost", port=8080)
 
# frozendict is hashable when all values are hashable
h = hash(config)   # works, returns an integer
print(h)           # something like -4782345923847234
 
# frozendict with unhashable value is not hashable
bad = frozendict(host="localhost", tags=["api", "web"])
hash(bad)  # TypeError: unhashable type: 'list'
 
# Hash is order-independent
a = frozendict(x=1, y=2)
b = frozendict(y=2, x=1)
print(hash(a) == hash(b))   # True
print(a == b)               # True
 
# Cross-type equality
print(frozendict(x=1, y=2) == dict(x=1, y=2))  # True

```

`hash(config)` computes the hash by hashing the frozenset of the frozendict's items. The pseudocode from PEP 814: `hash(frozenset(frozendict.items()))`. This is why order doesn't affect the hash — frozensets are unordered, so shuffling the keys produces the same frozenset of items and thus the same hash.

`hash(bad)` raises `TypeError` because `["api", "web"]` is a list, and lists are mutable and therefore unhashable. A `frozendict` is only hashable if all its values are hashable. This mirrors `tuple`: `hash((1, 2))` works, `hash((1, []))` raises.

The computed hash value gets cached in `PyFrozenDictObject.ma_hash` after the first call. Subsequent `hash()` calls return the cached value in O(1) without recomputing.

### Using frozendict as a Dict Key

```python
# frozendict can be a key in another dict
routing_table = {
    frozendict(src="10.0.0.1", dst="10.0.0.2"): "route_A",
    frozendict(src="10.0.0.3", dst="10.0.0.4"): "route_B",
}
 
query = frozendict(src="10.0.0.1", dst="10.0.0.2")
result = routing_table[query]   # "route_A"

```

`routing_table[query]` works because `frozendict` is hashable. Python computes `hash(query)`, finds the matching bucket in `routing_table`'s hash table, and returns `"route_A"`.

A regular `dict` cannot be a key because it's mutable — its contents (and therefore its hypothetical hash value) could change after insertion, breaking the hash table invariant.

### Using frozendict in a Set

```python
seen_configs = set()
 
cfg1 = frozendict(env="prod", version=3)
cfg2 = frozendict(env="staging", version=2)
cfg3 = frozendict(env="prod", version=3)    # same as cfg1
 
seen_configs.add(cfg1)
seen_configs.add(cfg2)
seen_configs.add(cfg3)   # duplicate — not added
 
print(len(seen_configs))  # 2, not 3
print(cfg1 in seen_configs)  # True

```

`seen_configs.add(cfg3)` does not increase the set size because `hash(cfg1) == hash(cfg3)` and `cfg1 == cfg3`. Set deduplication works exactly as with any other hashable type.

## The lru\_cache Problem This Solves

`functools.lru_cache` caches function results keyed on function arguments. The arguments must be hashable because they become keys in an internal dict. Pass a dict, get a `TypeError`.

```python
from functools import lru_cache
 
@lru_cache(maxsize=128)
def process_config(config):
    # expensive computation based on config
    return sum(config.values()) * len(config)
 
# This fails
process_config({"timeout": 30, "retries": 3})
# TypeError: unhashable type: 'dict'

```

Before `frozendict`, the workarounds were ugly. You'd convert to `frozenset(d.items())` but lose key access inside the function and have to reconstruct. You'd convert to a JSON string but lose types and performance. You'd write a custom `HDict` subclass that added `__hash__` but then had the hash-mutable-object problem.

With `frozendict`:

```python
from functools import lru_cache
 
@lru_cache(maxsize=128)
def process_config(config: frozendict) -> int:
    # config is immutable — safe to cache
    return sum(config.values()) * len(config)
 
cfg = frozendict(timeout=30, retries=3)
result = process_config(cfg)    # computed, cached
result2 = process_config(cfg)   # returned from cache
result3 = process_config(frozendict(retries=3, timeout=30))  # cache hit — order-independent hash
print(result == result2 == result3)  # True

```

`process_config(cfg)` on the first call runs the function body and caches the result. The cache key is `(cfg,)` — a tuple containing the frozendict argument. Since `frozendict` is hashable, this works cleanly.

`process_config(frozendict(retries=3, timeout=30))` hits the same cache entry as `process_config(frozendict(timeout=30, retries=3))` because the hash is order-independent. `frozendict(timeout=30, retries=3)` and `frozendict(retries=3, timeout=30)` have the same hash and compare equal.

This is a significant improvement over the `frozenset(d.items())` workaround, which required you to call `dict(config)` inside the function to get key access back. With `frozendict`, the function receives a proper mapping and uses it directly.

## Thread Safety and Free-Threaded Python

This is the use case that Hettinger dismissed in 2012 with "threads can just agree by convention." That argument had a shelf life. It expired in 2024 when Python 3.13 shipped the free-threaded build (no GIL).

The problem: in free-threaded Python, multiple threads execute Python bytecode in parallel. If two threads both read and write a shared `dict`, you have a data race. The GIL previously prevented this by only letting one thread execute Python at a time. With free threading, that protection is gone.

```python
# This is a race condition in free-threaded Python
shared_cache = {}
 
def worker(key, value):
    # Two threads doing this simultaneously can corrupt shared_cache
    shared_cache[key] = value
 
# This is safe — frozendict is read-only by design
DEFAULTS = frozendict(
    timeout=30,
    retries=3,
    encoding="utf-8",
)
 
def worker(request):
    # Multiple threads can read DEFAULTS concurrently with no synchronization
    timeout = DEFAULTS["timeout"]
    # ...

```

`DEFAULTS["timeout"]` across multiple threads is safe with `frozendict`. From the PEP: "Once a frozendict is created, its shallow immutability is guaranteed. This means it can be safely shared between threads without synchronization."

The caveat is "shallow immutability." The frozendict itself cannot be mutated. But if its values are mutable objects, those objects can still be mutated from other threads.

```python
# Shallow immutability — the frozendict is safe, its mutable values are not
shared = frozendict(config={"timeout": 30})  # value is a regular dict
 
# Thread A
shared["config"]["timeout"] = 60   # This works — mutating the VALUE, not the frozendict
 
# Thread B
print(shared["config"]["timeout"])  # might see 30 or 60 — race condition

```

`shared["config"]["timeout"] = 60` succeeds because `shared["config"]` returns the inner `dict`, and you're then calling `dict.__setitem__` on that inner dict. The frozendict protected its own keys and values references. It did not protect the objects those values point to.

If you need deep immutability, use frozensets and frozendicts all the way down.

## The Union Operators: Non-Destructive Updates

`frozendict` supports `|` and `|=`, but they work differently from `dict`.

```python
base = frozendict(host="localhost", port=8080)
override = frozendict(port=9000, debug=True)
 
# | creates a new frozendict — base and override unchanged
merged = base | override
print(merged)   # frozendict({'host': 'localhost', 'port': 9000, 'debug': True})
print(base)     # frozendict({'host': 'localhost', 'port': 8080}) — untouched
print(override) # frozendict({'port': 9000, 'debug': True}) — untouched
 
# |= rebinds the variable, does NOT modify in-place
d = frozendict(x=1)
original_id = id(d)
d |= frozendict(y=2)
print(d)               # frozendict({'x': 1, 'y': 2})
print(id(d))           # DIFFERENT from original_id — d now points to a new object

```

`base | override` creates a new `frozendict` containing all keys from both. Right side wins on conflicts. Neither `base` nor `override` changes.

`d |= frozendict(y=2)` looks like an in-place update but produces a new `frozendict` and rebinds `d` to point at it. The object `d` previously pointed to is untouched. The `id(d)` changes, which confirms you're looking at a different object after the operation.

This is identical to how `frozenset` handles `|=`. It's mutation of the variable binding, not the object. This can surprise people who come from languages where `|=` always mutates in-place.

You can also merge a frozendict with a regular dict:

```python
frozen = frozendict(x=1, y=2)
mutable = {"y": 99, "z": 3}
 
result = frozen | mutable   # frozendict({'x': 1, 'y': 99, 'z': 3})

```

`frozen | mutable` produces a `frozendict` with right-side keys winning. Mixing types works cleanly.

## Copy Semantics and the Shallow Copy Trap

```python
import copy
 
original = frozendict(data=[1, 2, 3], name="test")
 
# .copy() returns the same object in CPython — O(1)
shallow = original.copy()
print(shallow is original)  # True in CPython — same object
 
# Since both are immutable, sharing the object is safe
# The underlying hash table cannot be modified through either reference
 
# deep copy creates a new frozendict with deep-copied values
deep = copy.deepcopy(original)
print(deep is original)    # False — different object
 
# The trap: mutable VALUES are still shared in shallow copy
with_list = frozendict(tags=["a", "b"])
shallow_copy = with_list.copy()
 
with_list["tags"].append("c")   # Wait — this works? Yes. The LIST is mutable.
print(shallow_copy["tags"])     # ["a", "b", "c"] — shared reference, modified!

```

`original.copy()` in CPython returns the same `frozendict` object, not a copy. The rationale: the object is immutable, so returning a shared reference is safe and O(1). Two variables pointing to the same `frozendict` cannot interfere with each other because neither can mutate it.

`with_list["tags"].append("c")` succeeds because `with_list["tags"]` returns the list object `["a", "b"]`, and you're calling `.append()` on the list, not on the frozendict. The frozendict protected its key/value mapping. It did not freeze its values. The shallow copy shares the same list reference, so you see the mutation through both variables.

`copy.deepcopy(original)` creates a new `frozendict` where `tags` is a new list. Modifying the original list does not affect the deep copy.

## frozendict vs. The Other Options

You had options before 3.15\. They all had problems.

**`types.MappingProxyType`**

```python
from types import MappingProxyType
 
d = {"x": 1}
proxy = MappingProxyType(d)
 
proxy["x"]      # 1 — works
proxy["x"] = 2  # TypeError — can't mutate through proxy
hash(proxy)     # TypeError: unhashable type: 'mappingproxy'
 
# The trap: the underlying dict can still be mutated
d["x"] = 99
print(proxy["x"])   # 99 — proxy reflects mutations to the original

```

`hash(proxy)` fails. It cannot be used as a dict key, in a set, or with `lru_cache`. `MappingProxyType` is a read-only view, not an immutable object. The underlying dict it wraps can still be modified. The proxy is transparent to mutations in the source.

**`collections.frozenmap`** (if it existed, via PEP 603)

O(log n) lookups instead of O(1). Unordered. Never shipped as a standard type — the PEP was rejected.

**Third-party `frozendict` package on PyPI**

Solid library. The PyPI `frozendict` package works well and predates PEP 814\. But adding a package dependency for something this fundamental is friction. Now you don't need it for 3.15+.

**`dict` subclass with custom `__hash__`**

```python
class HashableDict(dict):
    def __hash__(self):
        return hash(frozenset(self.items()))

```

Lies. This is a mutable dict that pretends to be hashable. If you add a key after computing the hash, the hash changes, corrupting any hash table that cached the old value. This is the "mutable object in set" bug that causes extremely confusing behavior at runtime.

`frozendict` from builtins is better than all of these. Hashable, O(1) lookups, immutable by design at the C level, no inheritance tricks.

## The stdlib Is Getting Cleaned Up

PEP 814 includes a list of stdlib modules where the Python team will replace `dict` or `MappingProxyType` with `frozendict` for constants. The list is long. A sample:

- `dis.COMPILER_FLAG_NAMES` — compiler flag name mapping, constant
- `token.EXACT_TOKEN_TYPES` — token type mapping, constant
- `json.decoder._CONSTANTS` — JSON constant mapping, constant
- `json.encoder.ESCAPE_DCT` — escape character mapping, constant
- `ssl._PROTOCOL_NAMES` — TLS protocol name mapping, constant
- `opcode._cache_format` — opcode cache format, constant
- `errno.errorcode` — errno → string mapping, constant

These are cases where the dict was intended to be read-only but was expressed as a mutable dict because there was no better option. Now there is.

`builtins.eval()` and `exec()` will also be updated to accept `frozendict` for the `globals` argument. This matters for sandbox-adjacent use cases where you want to pass a controlled namespace to user-supplied code.

## What frozendict Does Not Do

A few things worth knowing before you go rewrite all your code.

**It does not make values immutable.**

```python
fd = frozendict(items=[1, 2, 3])
fd["items"].append(4)   # works — you modified the list, not the frozendict

```

`frozendict` is shallowly immutable. Values are not frozen. If you need deep immutability, you need all your values to also be immutable types.

**There is no literal syntax.**

```python
# You write this
fd = frozendict(x=1, y=2)
 
# Not this (deferred to a possible future PEP)
fd = @{"x": 1, "y": 2}   # does not exist in 3.15

```

The PEP deferred literal syntax to a future proposal. Various syntaxes were discussed (`@{}`, `{||}`, `frozen{}`), none reached consensus. For now, construction uses the same call syntax as `dict`.

**There is no O(1) conversion from dict.**

```python
# This is O(n) — copies all items
fd = frozendict(existing_dict)

```

A `.freeze()` method that mutates a dict into a frozendict in O(1) was proposed and deferred. The concern: changing the type of an existing object is dangerous. Other references to the dict would suddenly find it frozen. Brett Cannon pointed out this creates "side-effects at a distance" — a thread holding a reference to the dict could try to mutate it and get a `TypeError` it never expected. The O(n) shallow copy avoids that problem at the cost of memory and time. A future PEP may address this with explicit copy-on-write semantics.

## Practical Usage Patterns

```python
# Pattern 1: Configuration objects that shouldn't drift
DEFAULT_SETTINGS = frozendict(
    host="0.0.0.0",
    port=8000,
    workers=4,
    timeout=30,
)
 
def start_server(settings: frozendict = DEFAULT_SETTINGS):
    # settings is immutable — callers cannot accidentally corrupt DEFAULT_SETTINGS
    # by passing a dict and then mutating it
    print(f"Starting on {settings['host']}:{settings['port']}")

```

`DEFAULT_SETTINGS` as a module-level `frozendict` prevents the classic mutable default argument footgun. With a regular `dict` as default, a careless caller could mutate `DEFAULT_SETTINGS` and affect every subsequent call. `frozendict` makes that impossible.

```python
# Pattern 2: Dict of dicts — lookup table with complex keys
from functools import lru_cache
 
PERMISSION_SETS = {
    frozendict(role="admin", resource="users"):   frozenset({"read", "write", "delete"}),
    frozendict(role="viewer", resource="users"):  frozenset({"read"}),
    frozendict(role="admin", resource="billing"): frozenset({"read", "write"}),
}
 
def get_permissions(role: str, resource: str) -> frozenset:
    key = frozendict(role=role, resource=resource)
    return PERMISSION_SETS.get(key, frozenset())
 
perms = get_permissions("admin", "users")  # frozenset({'read', 'write', 'delete'})

```

`frozendict(role=role, resource=resource)` as a lookup key is cleaner than a tuple `(role, resource)` when the semantics are "a collection of named properties." The frozendict key makes the lookup readable. Compare to `PERMISSION_SETS.get((role, resource))` — the frozendict version tells you what the tuple elements mean.

```python
# Pattern 3: lru_cache with configuration arguments
from functools import lru_cache
import re
 
@lru_cache(maxsize=256)
def compile_filter(filter_config: frozendict) -> re.Pattern:
    """Compile a regex pattern based on filter configuration.
    Results are cached — same config always returns same compiled pattern.
    """
    pattern = filter_config.get("pattern", ".*")
    flags = re.IGNORECASE if filter_config.get("case_insensitive") else 0
    return re.compile(pattern, flags)
 
# Call with different configs — each unique config compiles once and caches
p1 = compile_filter(frozendict(pattern=r"\d+", case_insensitive=True))
p2 = compile_filter(frozendict(pattern=r"\d+", case_insensitive=True))   # cache hit
p3 = compile_filter(frozendict(pattern=r"[a-z]+", case_insensitive=False))
 
print(p1 is p2)  # True — same object from cache

```

`compile_filter` takes a `frozendict` as its argument. `lru_cache` can use it as a key because it's hashable. The function compiles the regex once per unique configuration and returns the cached `re.Pattern` on subsequent calls. This is the "expensive computation that depends on dict-shaped configuration" pattern that previously required ugly workarounds.

```python
# Pattern 4: Thread-safe shared read-only state
import threading
 
SHARED_CONFIG = frozendict(
    db_host="db.internal",
    db_port=5432,
    pool_size=10,
)
 
def database_worker():
    # No lock needed — frozendict is immutable, safe for concurrent reads
    host = SHARED_CONFIG["db_host"]
    port = SHARED_CONFIG["db_port"]
    # ... connect to database
    pass
 
threads = [threading.Thread(target=database_worker) for _ in range(10)]
for t in threads:
    t.start()

```

`SHARED_CONFIG["db_host"]` from ten concurrent threads needs no lock. The frozendict cannot be mutated. Its internal hash table is stable. No thread can modify it between another thread's hash computation and bucket lookup. This is the property the free-threaded Python GIL removal made urgent.

## Coming to Python 3.15, May 2026

PEP 814 status is Final as of February 11, 2026\. The CPython PR (`python/cpython/pull/141508`) is in progress. Beta freeze is May 5, 2026\. The type ships in 3.15.

If you're on Python 3.14 or earlier and need `frozendict` now, the `frozendict` package on PyPI is the production-tested equivalent. Install it, use the same API, and swap out the import when 3.15 ships.

```python
# Pre-3.15 compatibility shim
import sys
if sys.version_info >= (3, 15):
    from builtins import frozendict
else:
    from frozendict import frozendict  # pip install frozendict

```

`sys.version_info >= (3, 15)` checks the Python version at runtime. If 3.15 or later, the built-in type is used. Earlier versions fall back to the PyPI package. Same API, same behavior, transparent swap when you upgrade.

The thing that took fourteen years wasn't the idea. The idea was fine in 2012\. It was the ecosystem catching up — asyncio, free threading, independent subinterpreters, all of them creating the demand that Hettinger said was too low to justify the type. The demand was always there. Now it's undeniable and the language reflects that.