Skip to content

Immutability & const

Senior

The built-in immutable types, shallow vs deep immutability, typing.Final and Enum, and whether a custom object can truly be made immutable.

Created 12 September 2026 · Updated 23 October 2026

8 concepts · detail hidden

  1. 01The built-in immutable types — what operation each one disallows

    int, float, complex, bool, str, tuple, frozenset, bytes, and NoneType (trivially immutable, since there’s only ever one possible value, None, in the first place). Each one specifically disallows genuine in-place mutation of its contents: str has no __setitem__ at all (unlike a list, where item assignment is normal); tuple has neither .append() nor __setitem__ (again, unlike list); frozenset has neither .add() nor .remove() (unlike set). This design choice matters beyond mere definition: immutability is precisely what makes str/tuple/frozenset genuinely hashable, and therefore safely usable as dict keys or set members — a mutable object generally can’t safely serve that role, since its hash could change after insertion, breaking the container’s internal invariants.

    #c-the-built-in-immutable-types-what-operation-each-one-disallo
  2. 02Shallow immutability — what a `tuple` containing a `list` does and doesn't guarantee

    A tuple guarantees only that its own references — which specific objects occupy which positions — can never be reassigned, added, or removed after creation (my_tuple[0] = x is simply disallowed, and the tuple can never be resized). But if one of those references happens to point at a genuinely mutable object, such as a nested list, that inner object remains fully mutable through the tuple just as it would anywhere else — my_tuple[0].append(x) works perfectly fine, silently mutating the list “inside” what people often loosely call an “immutable” tuple. This is precisely shallow immutability, not full deep/recursive immutability — a genuinely important distinction many people gloss over, and a real, common source of “but I thought tuples were supposed to be immutable!” confusion and bugs.

    #c-shallow-immutability-what-a-tuple-containing-a-list-does-and
  3. 03Why Python has no runtime `const` — `typing.Final` and the `UPPER_CASE` convention

    The honest, direct answer is no — Python has genuinely no runtime-enforced constant binding mechanism at all. The widespread UPPER_CASE naming convention for “constants” is purely a social/style convention (per PEP 8), carrying absolutely no enforcement from the interpreter itself whatsoever — nothing whatsoever stops MY_CONSTANT = "a completely new value" from succeeding silently, exactly like any other ordinary reassignment. typing.Final (3.8+) is the closest thing Python offers — but consistent with a recurring pattern throughout the typing module, it’s genuinely static-only: checked by tools like mypy/pyright, but doing absolutely nothing whatsoever to prevent actual reassignment at runtime. Assuming Python genuinely enforces this at runtime is a common but incorrect assumption.

    #c-why-python-has-no-runtime-const-typingfinal-and-the-upper_ca
  4. 04Making a custom class immutable — the mechanisms, and why `object.__setattr__` bypasses all of them

    Ranked roughly by genuine robustness: NamedTuple gives fields that genuinely, structurally cannot be reassigned, backed by exactly the same underlying guarantees as ordinary tuple immutability. dataclass(frozen=True) overrides __setattr__/__delattr__ to actively raise FrozenInstanceError on any attempted assignment — genuinely robust against accidental misuse, but still fully bypassable by anyone deliberately calling object.__setattr__(instance, 'x', value) directly — meaning it’s “immutable against accidents,” not “immutable against a genuinely determined circumvention attempt.” A fully hand-rolled version, overriding __setattr__ yourself to unconditionally raise, has exactly this same fundamental bypass still available via object.__setattr__. The honest overall conclusion: Python simply has no mechanism that’s genuinely immutable against a sufficiently determined caller willing to reach for object.__setattr__ or direct __dict__ manipulation — every “immutable” custom class in Python is immutable by strong convention and friction, not by a truly unbreakable guarantee, unlike a language offering genuine, lower-level memory-protected const semantics.

    from dataclasses import dataclass, FrozenInstanceError
    
    @dataclass(frozen=True)
    class Money:
        amount: int
        currency: str
    
    m = Money(10, "EUR")
    try:
        m.amount = 20                      # generated __setattr__ raises
    except FrozenInstanceError as e:
        print(e)                           # -> cannot assign to field 'amount'
    
    object.__setattr__(m, "amount", 20)    # bypasses the override entirely
    print(m)                               # -> Money(amount=20, currency='EUR')
    

    The frozen=True mechanism is just generated __setattr__/__delattr__ methods on the class, which is why going one level up the MRO to object.__setattr__ skips them. The dataclass machinery itself uses exactly this bypass inside the generated __init__ (and you use it in __post_init__ to set derived fields on a frozen instance), so it’s a legitimate escape hatch for construction, not just an attack.

    #c-making-a-custom-class-immutable-the-mechanisms-and-why-objec
  5. 05`Enum` vs module-level constants — a real type for a closed set of named values

    Enum gives you a genuine, distinct type representing a fixed, closed set of named values — meaning a function can meaningfully type-annotate and validate “this parameter must be one of these specific Status members,” something plain module-level constants simply have no way to express as a genuine type constraint at all. It also gives you convenient iteration over all defined members, and — worth noting for completeness — it still doesn’t fundamentally prevent someone from monkey-patching a new attribute onto the enum class after the fact, or doing something genuinely unwise at a deeper metaclass level in unusual setups; but for the ordinary, everyday case, it remains a meaningfully stronger, more type-safe tool for expressing “this represents a closed, named set of options” than a handful of loose bare constants ever could be.

    #c-enum-vs-module-level-constants-a-real-type-for-a-closed-set-
  6. 06Preventing subclassing, instance-attribute addition, and class monkey-patching — what's possible

    Preventing subclassing specifically: yes, this works genuinely cleanly — a metaclass (or, more simply since 3.6, __init_subclass__ deliberately raising an error) can straightforwardly reject any attempt whatsoever to subclass. Preventing arbitrary new attribute addition specifically to instances: __slots__ blocks genuinely new instance attributes from being created, though notably it does nothing to prevent reassignment of attributes that already legitimately exist. Preventing monkey-patching of the class itself (replacing or adding a method directly on the class, after its original definition) is genuinely harder, and far less commonly attempted at all — Python’s classes are, by deliberate design, themselves fully mutable objects (their own __dict__, while presented externally as a read-only mappingproxy, remains genuinely replaceable at the class level via type.__setattr__) — and there’s simply no clean, standard, idiomatic “seal this class entirely” mechanism in the everyday toolkit, the way __slots__ cleanly covers the analogous instance-attribute case.

    #c-preventing-subclassing-instance-attribute-addition-and-class
  7. 08Practices teams use to approximate `const` safety without language support

    There’s no single silver bullet; teams typically layer several practices: static type checking using Final annotations, enforced in CI even though it provides zero runtime protection on its own; code-review conventions and automated linters specifically flagging any reassignment of UPPER_CASE-named identifiers; reaching deliberately for frozen=True dataclasses or NamedTuple specifically for genuine value objects (consciously accepting the object.__setattr__ escape hatch as a tolerable residual risk rather than a true, unbreakable guarantee); and simply, honestly, not needing this particular guarantee nearly as often in practice as a statically-const-enforcing language’s culture might suggest — since Python’s broader dynamic-by-default culture tends to lean on genuine engineering discipline and thorough tests more heavily than on compiler-enforced structural guarantees in general.

    #c-practices-teams-use-to-approximate-const-safety-without-lang