typing.ReadOnly lets you declare read-only keys inside a TypedDict. It is designed for dictionary-shaped data where some values may be established during construction but should not be replaced later. Typical examples include record identifiers, creation timestamps, deployment metadata, audit fields, and values supplied by trusted infrastructure.
This guide explains what ReadOnly means, how type checkers use it, why it does not make a dictionary immutable at runtime, and how to apply it safely in real Python projects.
The problem ReadOnly solves
A TypedDict describes the expected shape of a dictionary. It tells a static analyzer which keys exist and what type each value should have. Before read-only keys were available, every declared key was generally treated as writable. That made it difficult to express a common contract: a record may be mutable overall, while selected fields must remain stable.
Consider a user record. The display name and email may change, but the internal identifier should remain the same after the record is created.
from typing import ReadOnly, TypedDict
class User(TypedDict):
id: ReadOnly[int]
name: str
email: str
A type checker allows code to read user['id'], but reports an assignment such as user['id'] = 99. The other fields remain writable.
Static protection, not runtime immutability
ReadOnly does not replace the normal dictionary implementation. At runtime, the object is still a dict, and Python itself does not block an assignment to a read-only annotated key. The protection exists in static analysis performed by tools such as Pyright, mypy, IDE inspections, or a continuous integration check.
This distinction is essential. Type annotations document and verify intended usage before execution, while runtime validation enforces rules when the program is running. A robust project often needs both.
A configuration example
Application configuration often mixes fixed and adjustable values. The deployment environment and release version may be fixed, while the logging level and timeout may be changed through an administrative system.
from typing import ReadOnly, TypedDict
class AppConfig(TypedDict):
environment: ReadOnly[str]
version: ReadOnly[str]
log_level: str
timeout: float
config: AppConfig = {
'environment': 'production',
'version': '2.4.0',
'log_level': 'INFO',
'timeout': 10.0,
}
config['log_level'] = 'DEBUG'
Changing the log level is valid. Replacing the environment or version should be flagged by the type checker. The type definition therefore communicates the lifecycle of each field to every caller.
Optional read-only keys
A read-only key may also be optional. Combine the concept with NotRequired when a field can be absent but should not be replaced once supplied.
from typing import NotRequired, ReadOnly, TypedDict
class ApiResponse(TypedDict):
request_id: ReadOnly[str]
result: str
cache_key: NotRequired[ReadOnly[str]]
Here, cache_key may not exist in every response. When it is present, callers are expected to treat it as stable metadata. This is useful for HTTP responses, event payloads, database projections, and messages produced by other services.
Function contracts
Read-only keys improve function signatures because they show which parts of a structure a function may inspect but should not replace.
class Order(TypedDict):
code: ReadOnly[str]
status: str
total: float
def mark_paid(order: Order) -> None:
order['status'] = 'paid'
# order['code'] = 'other' # typing error
The contract is more precise than a plain dictionary annotation. Reviewers can see that the order code represents identity, while status is expected to evolve.
Structural typing and aliases
TypedDict uses structural typing. Compatibility depends on the available keys and their value types, rather than only on the class name. Read-only qualifiers participate in that compatibility analysis. This matters when the same dictionary is visible through multiple aliases.
A writable alias must not be used to silently violate a promise made through a read-only view. Let the type checker evaluate assignments and avoid broad casts that erase qualifiers. When a conversion appears necessary, consider copying the data or designing a narrower input type instead.
ReadOnly versus frozen dataclasses
ReadOnly is not a substitute for @dataclass(frozen=True). A frozen dataclass provides a runtime barrier against ordinary attribute assignment. It also supports methods, properties, validation patterns, and richer domain behavior. A TypedDict, by contrast, remains ideal when the data must preserve dictionary semantics, especially for JSON, HTTP payloads, configuration mappings, and interoperability with existing APIs.
Choose read-only keys when only selected dictionary fields need static protection. Choose an immutable class when the whole object should behave as a value object and runtime enforcement is important.
Runtime validation is still required
External input must be checked even when the target type uses ReadOnly. Type annotations do not verify JSON received from a network, values read from a file, or dictionaries produced by untyped code.
def build_user(data: dict[str, object]) -> User:
identifier = data.get('id')
name = data.get('name')
email = data.get('email')
if not isinstance(identifier, int):
raise ValueError('invalid id')
if not isinstance(name, str) or not isinstance(email, str):
raise ValueError('invalid user data')
return {'id': identifier, 'name': name, 'email': email}
This factory validates the runtime values and returns data that matches the static contract. Larger systems may use a validation library, but the separation remains the same: validation checks reality, while typing checks program usage.
Version compatibility
Before adopting the feature, confirm the minimum Python version and the level of support in your selected type checker. Projects that support older Python versions may import a backport from typing_extensions. Keep both the interpreter and type-checking tools updated, because new typing features often require coordinated support.
The authoritative references are the Python typing documentation and PEP 705, which defines read-only items for TypedDict.
Good design practices
Use read-only qualifiers for fields that represent identity, provenance, generated metadata, or values whose replacement would break an invariant. Do not mark every field read-only automatically. If almost the entire structure must be immutable, a frozen dataclass or another value-object design will usually be clearer.
Avoid using cast merely to silence an error. Investigate whether the attempted mutation is actually valid. Place construction and validation in small functions, document when each stable value is assigned, and run static analysis in continuous integration.
Working with related Python features
For additional context, explore Academify guides on Python typing, Python dataclasses, Python dictionaries, and JSON in Python. Together, these topics help you choose between typed mappings, validated payloads, and dedicated classes.
Practical migration strategy
Introduce ReadOnly gradually. Start with identifiers and timestamps that are already treated as stable by convention. Run the type checker, fix genuine mutation sites, and add tests around construction and update workflows. This approach prevents a large annotation change from hiding design mistakes.
When a legitimate workflow needs a new value, create a new record instead of mutating the protected key, or redesign the type so the lifecycle is explicit. For example, a temporary draft record and a persisted record can use different TypedDict definitions.
Conclusion
typing.ReadOnly makes TypedDict contracts more expressive. It distinguishes stable keys from ordinary mutable fields, helps type checkers catch accidental replacement, and improves documentation for APIs that exchange dictionary-shaped data.
Because the annotation is static, it should be combined with runtime validation, tests, and a consistently executed type checker. Used selectively, it offers a lightweight way to preserve important invariants without giving up the compatibility and convenience of Python dictionaries.







