Important
This PEP is a historical document: seereadonly andtyping.ReadOnly for up-to-date specs and documentation. Canonical typing specs are maintained at thetyping specs site; runtime typing behaviour is described in the CPython documentation.
×
See thetyping specification update process for how to propose changes to the typing spec.
PEP 589 defines the structural typeTypedDict for dictionaries with a fixed set of keys.AsTypedDict is a mutable type, it is difficult to correctly annotate methods which accept read-only parameters in a way that doesn’t prevent valid inputs.
This PEP proposes a new type qualifier,typing.ReadOnly, to support these usages. It makes no Python grammar changes. Correct usage of read-only keys of TypedDicts is intended to be enforced only by static type checkers, and will not be enforced by Python itself at runtime.
Representing structured data using (potentially nested) dictionaries with string keys is a common pattern in Python programs.PEP 589 allows these values to be type checked when the exact type is known up-front, but it is hard to write read-only code that accepts more specific variants: for instance, where values may be subtypes or restrict a union of possible types. This is an especially common issue when writing APIs for services, which may support a wide range of input structures, and typically do not need to modify their input.
Consider trying to add type hints to a functionmovie_string:
defmovie_string(movie:Movie)->str:ifmovie.get("year")isNone:returnmovie["name"]else:returnf'{movie["name"]} ({movie["year"]})'
We could define thisMovie type using aTypedDict:
fromtypingimportNotRequired,TypedDictclassMovie(TypedDict):name:stryear:NotRequired[int|None]
But suppose we have another type where year is required:
classMovieRecord(TypedDict):name:stryear:int
Attempting to pass aMovieRecord intomovie_string results in the error (using mypy):
Argument 1 to "movie_string" has incompatible type "MovieRecord"; expected "Movie"
This particular use case should be type-safe, but the type checker correctly stops theuser from passing aMovieRecord into aMovie parameter in the general case, becausetheMovie class has mutator methods that could potentially allow the function to breakthe type constraints inMovieRecord (e.g. withmovie["year"]=None ordelmovie["year"]).The problem disappears if we don’t have mutator methods inMovie. This could be achieved by defining an immutable interface using aPEP 544Protocol:
fromtypingimportLiteral,Protocol,overloadclassMovie(Protocol):@overloaddefget(self,key:Literal["name"])->str:...@overloaddefget(self,key:Literal["year"])->int|None:...@overloaddef__getitem__(self,key:Literal["name"])->str:...@overloaddef__getitem__(self,key:Literal["year"])->int|None:...
This is very repetitive, easy to get wrong, and is still missing important method definitions like__contains__() andkeys().
The structural typing ofTypedDict is supposed to permit writing update functions that only constrain the types of items they modify:
classHasTimestamp(TypedDict):timestamp:floatclassLogs(TypedDict):timestamp:floatloglines:list[str]defupdate_timestamp(d:HasTimestamp)->None:d["timestamp"]=now()defadd_logline(logs:Logs,logline:str)->None:logs["loglines"].append(logline)update_timestamp(logs)# Accepted by type checker
However, this no longer works once you start nesting dictionaries:
classHasTimestampedMetadata(TypedDict):metadata:HasTimestampclassUserAudit(TypedDict):name:strmetadata:Logsdefupdate_metadata_timestamp(d:HasTimestampedMetadata)->None:d["metadata"]["timestamp"]=now()defrename_user(d:UserAudit,name:str)->None:d["name"]=nameupdate_metadata_timestamp(d)# Type check error: "metadata" is not of type HasTimestamp
This looks like an error, but is simply due to the (unwanted) ability to overwrite themetadata item held by theHasTimestampedMetadata instance with a differentHasTimestamp instance, that may no longer be aLogs instance.
It is possible to work around this issue with generics (as of Python 3.11), but it is very complicated, requiring a type parameter for every nested dict.
These problems can be resolved by removing the ability to update one or more of the items in aTypedDict. This does not mean the items are immutable: a reference to the underlying dictionary could still exist with a different but compatible type in which those items have mutator operations. These items are “read-only”, and we introduce a newtyping.ReadOnly type qualifier for this purpose.
Themovie_string function in the first motivating example can then be typed as follows:
fromtypingimportNotRequired,ReadOnly,TypedDictclassMovie(TypedDict):name:ReadOnly[str]year:ReadOnly[NotRequired[int|None]]defmovie_string(movie:Movie)->str:ifmovie.get("year")isNone:returnmovie["name"]else:returnf'{movie["name"]} ({movie["year"]})'
A mixture of read-only and non-read-only items is permitted, allowing the second motivating example to be correctly annotated:
classHasTimestamp(TypedDict):timestamp:floatclassHasTimestampedMetadata(TypedDict):metadata:ReadOnly[HasTimestamp]defupdate_metadata_timestamp(d:HasTimestampedMetadata)->None:d["metadata"]["timestamp"]=now()classLogs(HasTimestamp):loglines:list[str]classUserAudit(TypedDict):name:strmetadata:Logsdefrename_user(d:UserAudit,name:str)->None:d["name"]=nameupdate_metadata_timestamp(d)# Now OK
In addition to these benefits, by flagging arguments of a function as read-only (by using aTypedDict likeMovie with read-only items), it makes explicit not just to typecheckers but also to users that the function is not going to modify its inputs, which is usually a desirable property of a function interface.
This PEP proposes makingReadOnly valid only in aTypedDict. A possible future extension would be to support it in additional contexts, such as in protocols.
A newtyping.ReadOnly type qualifier is added.
typing.ReadOnly type qualifierThetyping.ReadOnly type qualifier is used to indicate that an item declared in aTypedDict definition may not be mutated (added, modified, or removed):
fromtypingimportReadOnlyclassBand(TypedDict):name:strmembers:ReadOnly[list[str]]blur:Band={"name":"blur","members":[]}blur["name"]="Blur"# OK: "name" is not read-onlyblur["members"]=["Damon Albarn"]# Type check error: "members" is read-onlyblur["members"].append("Damon Albarn")# OK: list is mutable
Thealternative functional syntax for TypedDict also supports the new type qualifier:
Band=TypedDict("Band",{"name":str,"members":ReadOnly[list[str]]})
ReadOnly[] can be used withRequired[],NotRequired[] andAnnotated[], in any nesting order:
classMovie(TypedDict):title:ReadOnly[Required[str]]# OKyear:ReadOnly[NotRequired[Annotated[int,ValueRange(-9999,9999)]]]# OK
classMovie(TypedDict):title:Required[ReadOnly[str]]# OKyear:Annotated[NotRequired[ReadOnly[int]],ValueRange(-9999,9999)]# OK
This is consistent with the behavior introduced inPEP 655.
Subclasses can redeclare read-only items as non-read-only, allowing them to be mutated:
classNamedDict(TypedDict):name:ReadOnly[str]classAlbum(NamedDict):name:stryear:intalbum:Album={"name":"Flood","year":1990}album["year"]=1973album["name"]="Dark Side Of The Moon"# OK: "name" is not read-only in Album
If a read-only item is not redeclared, it remains read-only:
classAlbum(NamedDict):year:intalbum:Album={"name":"Flood","year":1990}album["name"]="Dark Side Of The Moon"# Type check error: "name" is read-only in Album
Subclasses can narrow value types of read-only items:
classAlbumCollection(TypedDict):albums:ReadOnly[Collection[Album]]classRecordShop(AlbumCollection):name:stralbums:ReadOnly[list[Album]]# OK: "albums" is read-only in AlbumCollection
Subclasses can require items that are read-only but not required in the superclass:
classOptionalName(TypedDict):name:ReadOnly[NotRequired[str]]classRequiredName(OptionalName):name:ReadOnly[Required[str]]d:RequiredName={}# Type check error: "name" required
Subclasses can combine these rules:
classOptionalIdent(TypedDict):ident:ReadOnly[NotRequired[str|int]]classUser(OptionalIdent):ident:str# Required, mutable, and not an int
Note that these are just consequences of structural typing, but they are highlighted here as the behavior now differs from the rules specified inPEP 589.
This section updates the type consistency rules introduced inPEP 589to cover the new feature in this PEP. In particular, any pair of types that do not use the new feature will be consistent under these new rules if (and only if) they were already consistent.
A TypedDict typeA is consistent with TypedDictB ifA is structurally compatible withB. This is true if and only if all of the following are satisfied:
B,A has the corresponding key, unless the item inB is read-only, not required, and of top value type (ReadOnly[NotRequired[object]]).B, ifA has the corresponding key, the corresponding value type inA is consistent with the value type inB.B, its value type is consistent with the corresponding value type inA.B, the corresponding key is required inA.B, if the item is not read-only inB, the corresponding key is not required inA.Discussion:
ReadOnly[NotRequired[object]].Sequence, and different from non-read-only items, which behave invariantly. Example:classA(TypedDict):x:ReadOnly[int|None]classB(TypedDict):x:intdeff(a:A)->None:print(a["x"]or0)b:B={"x":1}f(b)# Accepted by type checker
A with no explicit key'x' is not consistent with a TypedDict typeB with a non-required key'x', since at runtime the key'x' could be present and have an incompatible type (which may not be visible throughA due to structural subtyping). The only exception to this rule is if the item inB is read-only, and the value type is of top type (object). For example:classA(TypedDict):x:intclassB(TypedDict):x:inty:ReadOnly[NotRequired[object]]a:A={"x":1}b:B=a# Accepted by type checker
In addition to existing type checking rules, type checkers should error if a TypedDict with a read-only item is updated with another TypedDict that declares that key:
classA(TypedDict):x:ReadOnly[int]y:inta1:A={"x":1,"y":2}a2:A={"x":3,"y":4}a1.update(a2)# Type check error: "x" is read-only in A
Unless the declared value is of bottom type (Never):
classB(TypedDict):x:NotRequired[typing.Never]y:ReadOnly[int]defupdate_a(a:A,b:B)->None:a.update(b)# Accepted by type checker: "x" cannot be set on b
Note: Nothing will ever match theNever type, so an item annotated with it must be absent.
PEP 692 introducedUnpack to annotate**kwargs with aTypedDict. Marking one or more of the items of aTypedDict used in this way as read-only will have no effect on the type signature of the method. However, itwill prevent the item from being modified in the body of the function:
classArgs(TypedDict):key1:intkey2:strclassReadOnlyArgs(TypedDict):key1:ReadOnly[int]key2:ReadOnly[str]classFunction(Protocol):def__call__(self,**kwargs:Unpack[Args])->None:...defimpl(**kwargs:Unpack[ReadOnlyArgs])->None:kwargs["key1"]=3# Type check error: key1 is readonlyfn:Function=impl# Accepted by type checker: function signatures are identical
TypedDict types will gain two new attributes,__readonly_keys__ and__mutable_keys__, which will be frozensets containing all read-only and non-read-only keys, respectively:
classExample(TypedDict):a:intb:ReadOnly[int]c:intd:ReadOnly[int]assertExample.__readonly_keys__==frozenset({'b','d'})assertExample.__mutable_keys__==frozenset({'a','c'})
typing.get_type_hints will strip out anyReadOnly type qualifiers, unlessinclude_extras isTrue:
assertget_type_hints(Example)['b']==intassertget_type_hints(Example,include_extras=True)['b']==ReadOnly[int]
typing.get_origin andtyping.get_args will be updated to recognizeReadOnly:
assertget_origin(ReadOnly[int])isReadOnlyassertget_args(ReadOnly[int])==(int,)
This PEP adds a new feature toTypedDict, so code that inspectsTypedDict types will have to change to support types using it. This is expected to mainly affect type-checkers.
There are no known security consequences arising from this PEP.
Suggested changes to thetyping module documentation, in line with current practice:
typing.ReadOnly, linked to TypedDict and this PEP.TheReadOnly type qualifier indicates that an item declared in aTypedDict definition may be read but not mutated (added, modified or removed). This is useful when the exact type of the value is not known yet, and so modifying it would break structural subtypes.insert example
An earlier version of this PEP proposed aTypedMapping protocol type, behaving much like a read-only TypedDict but without the constraint that the runtime type be adict. The behavior described in the current version of this PEP could then be obtained by inheriting a TypedDict from a TypedMapping. This has been set aside for now as more complex, without a strong use-case motivating the additional complexity.
A generalized higher-order type could be added that removes mutator methods from its parameter, e.g.ReadOnly[MovieRecord]. For a TypedDict, this would be like addingReadOnly to every item, including those declared in superclasses. This would naturally want to be defined for a wider set of types than just TypedDict subclasses, and also raises questions about whether and how it applies to nested types. We decided to keep the scope of this PEP narrower.
ReadonlyRead-only is generally hyphenated, and it appears to be common convention to put initial caps onto words separated by a dash when converting to CamelCase. This appears consistent with the definition of CamelCase on Wikipedia: CamelCase uppercases the first letter of each word. That said, Python examples or counter-examples, ideally from the core Python libraries, or better explicit guidance on the convention, would be greatly appreciated.
Final annotationTheFinal annotation prevents an attribute from being modified, like the proposedReadOnly qualifier does forTypedDict items. However, it is also documented as preventing redefinition in subclasses too; fromPEP 591:
Thetyping.Finaltype qualifier is used to indicate that a variable or attribute should not be reassigned, redefined, or overridden.
This does not fit with the intended use ofReadOnly. Rather than introduce confusion by havingFinal behave differently in different contexts, we chose to introduce a new qualifier.
Earlier versions of this PEP introduced a boolean flag that would ensure all items in a TypedDict were read-only:
classMovie(TypedDict,readonly=True):name:stryear:NotRequired[int|None]movie:Movie={"name":"A Clockwork Orange"}movie["year"]=1971# Type check error: "year" is read-only
However, this led to confusion when inheritance was introduced:
classA(TypedDict):key1:intclassB(A,TypedDict,readonly=True):key2:intb:B={"key1":1,"key2":2}b["key1"]=4# Accepted by type checker: "key1" is not read-only
It would be reasonable for someone familiar withfrozen (fromdataclasses), on seeing just the definition of B, to assume that the whole type was read-only. On the other hand, it would be reasonable for someone familiar withtotal to assume that read-only only applies to the current type.
The original proposal attempted to eliminate this ambiguity by making it both a type check and a runtime error to defineB in this way. This was still a source of surprise to people expecting it to work liketotal.
Given that no extra types could be expressed with thereadonly flag, it has been removed from the proposal to avoid ambiguity and surprise.
An earlier version of this PEP mandated that code like the following be supported by type-checkers:
classA(TypedDict):x:ReadOnly[int]classB(TypedDict):x:ReadOnly[str]classC(TypedDict):x:int|strdefcopy_and_modify(a:A)->C:c:C=copy.copy(a)ifnotc['x']:c['x']="N/A"returncdefmerge_and_modify(a:A,b:B)->C:c:C=a|bifnotc['x']:c['x']="N/A"returnc
However, there is currently no way to express this in the typeshed, meaning type-checkers would be forced to special-case these functions. There is already a way to code these operations that mypy and pyright do support, though arguably this is less readable:
copied:C={**a}merged:C={**a,**b}
While not as flexible as would be ideal, the current typeshed stubs are sound, and remain so if this PEP is accepted. Updating the typeshed would require new typing features, like a type constructor to express the type resulting from merging two or more dicts, and a type qualifier to indicate a returned value is not shared (so may have type constraints like read-only and invariance of generics loosened in specific ways), plus details of how type-checkers would be expected to interpret these features. These could be valuable additions to the language, but are outside the scope of this PEP.
Given this, we have deferred any update of the typeshed stubs.
Consider the following “type discrimination” code:
classA(TypedDict):foo:intclassB(TypedDict):bar:intdefget_field(d:A|B)->int:if"foo"ind:returnd["foo"]# !!!else:returnd["bar"]
This is a common idiom, and other languages like Typescript allow it. Technically, however, this code is unsound:B does not declarefoo, but instances ofB may still have the key present, and the associated value may be of any type:
classC(TypedDict):foo:strbar:intc:C={"foo":"hi","bar"3}b:B=c# OK: C is structurally compatible with Bv=get_field(b)# Returns a string at runtime, not an int!
mypy rejects the definition ofget_field on the marked line with the errorTypedDict"B"hasnokey"foo", which is a rather confusing error message, but is caused by this unsoundness.
One option for correcting this would be to explicitly preventB from holding afoo:
classB(TypedDict):foo:NotRequired[Never]bar:intb:B=c# Type check error: key "foo" not allowed in B
However, this requires every possible key that might be used to discriminate on to be explicitly declared in every type, which is not generally feasible. A better option would be to have a way of preventing all unspecified keys from being included inB. mypy supports this using the@final decorator fromPEP 591:
@finalclassB(TypedDict):bar:int
The reasoning here is that this preventsC or any other type from being considered a “subclass” ofB, so instances ofB can now be relied on to never hold the keyfoo, even though it is not explicitly declared to be of bottom type.
With the introduction of read-only items, however, this reasoning would imply type-checkers should ban the following:
@finalclassD(TypedDict):field:ReadOnly[Collection[str]]@finalclassE(TypedDict):field:list[str]e:E={"field":["value1","value2"]}d:D=e# Error?
The conceptual problem here is that TypedDicts are structural types: they cannot really be subclassed. As such, using@final on them is not well-defined; it is certainly not mentioned inPEP 591.
An earlier version of this PEP proposed resolving this by adding a new flag toTypedDict that would explicitly prevent other keys from being used, but not other kinds of structural compatibility:
classB(TypedDict,other_keys=Never):bar:intb:B=c# Type check error: key "foo" not allowed in B
However, during the process of drafting, the situation changed:
other_keys functionalityAs such, there is less urgency to address this issue in this PEP, and it has been deferred to PEP-728.
This document is placed in the public domain or under theCC0-1.0-Universal license, whichever is more permissive.
Source:https://github.com/python/peps/blob/main/peps/pep-0705.rst
Last modified:2025-11-07 04:32:09 GMT