Python allows names to be reassigned, methods to be overridden, and most classes to be subclassed. That flexibility is valuable, but some parts of an API should remain stable: constants should not receive another value, selected instance attributes should be assigned only once, certain methods should not be overridden, and some classes were not designed for inheritance. The typing module provides Final and the @final decorator to express these intentions to static checkers.
This guide covers module constants, class and instance attributes, the difference between Final and real immutability, dataclasses, configuration, final methods and classes, runtime enforcement, and common design mistakes.
A module constant
from typing import Final
MAX_ATTEMPTS: Final[int] = 3
API_URL: Final = "https://api.example.com"The first form declares both the type and final intent. The second lets the checker infer str. A later reassignment should be reported:
MAX_ATTEMPTS = 5 # type-checking errorPython can still execute that assignment at runtime. Final is a static contract, not an automatic interpreter-level restriction.
Final does not freeze the object
ROUTES: Final[list[str]] = ["/home"]
ROUTES.append("/help") # allowed
ROUTES = [] # should be rejectedFinal prevents rebinding the name, but it does not make the referenced list immutable. For an immutable structure, choose an appropriate runtime representation:
FIXED_ROUTES: Final[tuple[str, ...]] = ("/home", "/help")Final protects the name statically, while the tuple provides structural immutability at runtime.
Naming conventions and Final
Uppercase constants are only a convention. Final adds a rule that a checker can verify.
DEFAULT_TIMEOUT: Final[float] = 5.0Use both: uppercase communicates intent to readers, and Final communicates it to tooling.
Class attributes
class ProtocolVersion:
VERSION: Final[str] = "1.0"A subclass should not redefine a Final attribute:
class NewProtocol(ProtocolVersion):
VERSION = "2.0" # expected static errorIf subclass-specific versions are legitimate, do not mark the attribute Final. The annotation must reflect a real hierarchy invariant.
Instance attributes assigned once
class Session:
id: Final[str]
def __init__(self, id_value: str) -> None:
self.id = id_valueA later assignment should be rejected:
session = Session("abc")
session.id = "xyz" # type errorThis still does not block assignment at runtime. Use a read-only property, a frozen dataclass, or custom __setattr__ logic when runtime enforcement matters.
Where to initialize a Final attribute
A Final attribute should be initialized clearly and exactly once, usually in the class body or __init__. Complex conditional assignment may be harder for checkers to understand.
class Request:
token: Final[str]
def __init__(self, token: str | None) -> None:
value = generate_token() if token is None else token
self.token = valueComputing the value first and assigning once is often the clearest pattern.
Final in dataclasses
from dataclasses import dataclass
@dataclass
class Event:
id: Final[str]
payload: dict[str, object]Support may vary between checkers because the dataclass generates __init__. For runtime immutability, use @dataclass(frozen=True):
@dataclass(frozen=True)
class ImmutableEvent:
id: str
payload: dict[str, object]A frozen dataclass still does not freeze mutable objects stored inside it. The payload dictionary can be changed unless an immutable mapping is used.
Final and ClassVar
ClassVar says an attribute belongs to the class rather than instances. Final says it should not be redefined. Combining them may have syntax or checker limitations depending on the supported Python version. A Final annotation in the class body often communicates enough.
Use ClassVar without Final when subclasses are expected to configure the value. Use Final when the value is fixed across the hierarchy and verify behavior with the project’s checker.
Final and Literal
Final protects a name; Literal describes exact possible values.
from typing import Literal
DEFAULT_MODE: Final[Literal["safe"]] = "safe"This combination is rarely necessary for a simple constant. Literal is especially useful in parameters and return types, while Final is useful for definitions that should not change.
Final and NewType
from typing import NewType
UserId = NewType("UserId", int)
SYSTEM_USER: Final[UserId] = UserId(1)NewType preserves the semantic identity of the value and Final prevents static reassignment of the name.
The @final decorator on methods
from typing import final
class Authenticator:
@final
def verify_signature(self, token: str) -> bool:
return verify(token)A subclass should not override the method:
class CustomAuthenticator(Authenticator):
def verify_signature(self, token: str) -> bool:
return True # expected static errorThe decorator communicates that the algorithm is a fixed part of the contract. Use it selectively because preventing overrides reduces extensibility.
@final on classes
@final
class InternalToken:
def __init__(self, value: str) -> None:
self.value = valueA checker should reject subclassing:
class SpecialToken(InternalToken): # error
passThis can be appropriate when subclassing could break invariants, when the implementation relies on internal optimizations, or when composition is the intended extension model.
@final does not block inheritance at runtime
The typing decorator does not normally stop the interpreter from creating a subclass. Modern versions may set a reflection marker such as __final__, but enforcement still depends on tools or custom runtime logic.
class ClosedClass:
def __init_subclass__(cls) -> None:
raise TypeError("subclassing is not allowed")Use runtime blocking only when it is truly required, because it changes normal Python behavior.
Template methods with final steps
class Importer:
def run(self, path: str) -> None:
data = self.read(path)
data = self.transform(data)
self.save(data)
def read(self, path: str) -> bytes:
raise NotImplementedError
def transform(self, data: bytes) -> bytes:
return data
@final
def save(self, data: bytes) -> None:
write_with_audit(data)Reading and transformation remain extension points, while saving is fixed because auditing is mandatory.
Final in Protocol
Protocol describes structural behavior, while Final and @final primarily constrain nominal implementations and inheritance. A structurally compatible class does not have to inherit from the Protocol, so final markers on the Protocol often provide limited value.
Final and read-only properties
A property without a setter gives practical runtime protection:
class Account:
def __init__(self, number: str) -> None:
self._number = number
@property
def number(self) -> str:
return self._numberThe internal storage can also be annotated Final to reinforce static intent.
Runtime-loaded configuration
A value may become final after initialization even when it comes from an environment variable or file.
class Configuration:
environment: Final[str]
timeout: Final[float]
def __init__(self) -> None:
self.environment = read_environment()
self.timeout = read_timeout()Final does not require a compile-time constant. It requires that the attribute not be reassigned after initialization.
Imports and monkey patching
Importing a Final name into another module does not prevent dynamic changes. Python also allows module and class attributes to be monkey patched. Final communicates that such changes violate the contract but does not disable them. Prefer explicit dependency-injection points in tests instead of patching final members.
When not to use Final
- Subclasses are expected to configure the value.
- Reassignment is part of the lifecycle.
- The public API is intentionally extensible.
- You need object immutability rather than name stability.
- The project does not run a static checker.
Common mistakes
- Confusing Final with a runtime const: Python still permits assignment.
- Annotating a list and expecting it to freeze: the contents remain mutable.
- Marking every method final: the hierarchy becomes difficult to extend.
- Marking configurable values final: the annotation contradicts the design.
- Using unclear initialization paths: checkers may reject or misinterpret them.
- Treating Final as security: permissions and data still need runtime validation.
Complete stable API client example
from typing import Final, final
DEFAULT_URL: Final[str] = "https://api.example.com"
class ApiClient:
url: Final[str]
_version_header: Final[str] = "X-Api-Version"
def __init__(self, url: str = DEFAULT_URL) -> None:
self.url = url.rstrip("/")
def get(self, path: str) -> bytes:
return self._send("GET", path)
@final
def _send(self, method: str, path: str) -> bytes:
headers = {self._version_header: "1"}
return http_transport(
method,
f"{self.url}/{path.lstrip('/')}",
headers=headers,
)
class CachedClient(ApiClient):
def get(self, path: str) -> bytes:
if data := cache_get(path):
return data
data = super().get(path)
cache_set(path, data)
return dataEach instance URL is assigned once, the header name is fixed across the hierarchy, and low-level sending cannot be overridden. The high-level get() operation remains extensible for caching.
Testing the contract
Run a checker in continuous integration and keep type-checking fixtures for invalid reassignments and overrides. Runtime tests should verify real immutability only when it is implemented through tuples, read-only properties, frozen dataclasses, or other concrete mechanisms.
Conclusion
typing.Final protects names and attributes from reassignment in the static contract, while @final prevents method overriding and class inheritance for checkers. They document invariants and reduce accidental extension.
The official Python Final documentation and final decorator documentation define the semantics. Combine them with immutable structures or runtime controls when the rule must be enforced by the program itself.







