2026, Jan 05 07:00

Read-only class property in Python: descriptor-based access and metaclass enforcement that replaces @classmethod+@property

Why @classmethod+@property was disabled in Python 3.13, and how to get a read-only class property with descriptor and metaclass, usable on class and instances.

Class-level state that behaves like a read-only property is a recurring need. Many developers reach first for a decorator chain such as @classmethod and @property to emulate a “class property”. This used to kind of work, but it was deprecated in Python 3.11 and explicitly disabled in Python 3.13. Below is a concise walkthrough of why that happened, what the common workarounds miss, and a practical pattern that gives you a class property that is readable from both the class and its instances while rejecting reassignment.

What breaks and why chaining was disabled

Chaining @classmethod and @property was deprecated in Python 3.11 and then disabled in Python 3.13. The reason given is that such an attribute cannot be reliably recognized by inspection code as an instance of property. In other words, introspection cannot see the decorated attribute as a property, which leads to inconsistency in tooling and behavior. Once this was formalized, relying on the decorator chain became untenable.

A tempting workaround that doesn’t protect against reassignment

A common approach is to create a descriptor that looks like property but fetches its value from the class. In the simplest form, it can read correctly—but it does not stop a direct assignment on the class, so the attribute can be overwritten.

class ClassAttr(property):
    def __get__(self, inst, owner):
        return self.fget(owner)

    def __set__(self, inst, val):
        raise AttributeError("can't set attribute")


class Alpha(object):
    @ClassAttr
    def token(cls):
        return 1


print(Alpha.token)
Alpha.token = 2
print(Alpha.token)  # 2; no error raised, reassignment succeeded

As the output shows, attempted modification is not prevented in this form. The attribute read works, but a write silently wins.

Metaclass property: read-only, but not from instances

Another approach is to place the property on the metaclass. This blocks writes, but it becomes visible only on the class object itself, not on instances, which is often not the desired user experience.

class AlphaType(type):
    @property
    def token(cls):
        return 1


class Alpha(object, metaclass=AlphaType):
    pass


print(Alpha.token)
# Alpha.token = 2  # AttributeError: property 'token' of 'AlphaType' object has no setter
# print(Alpha().token)  # AttributeError: 'Alpha' object has no attribute 'token'

This pattern does ensure immutability at the class level, but you lose the ability to access the attribute uniformly via instances.

A practical pattern: descriptor for reads, metaclass gate for writes

To satisfy both constraints—readability from the class and from instances, plus protection from reassignment—you can combine a descriptor that reads from the class with a metaclass that intercepts and rejects writes targeting such attributes. The descriptor handles uniform access, while the metaclass enforces immutability.

class ClassAttr(property):
    def __get__(self, inst, owner):
        return self.fget(owner)


class AlphaType(type):
    def __setattr__(cls, key, val):
        if isinstance(vars(cls).get(key), ClassAttr):
            raise AttributeError("can't set attribute")
        super().__setattr__(key, val)


class Alpha(metaclass=AlphaType):
    @ClassAttr
    def token(cls):
        return 1


print(Alpha.token)  # 1
print(Alpha().token)  # 1
# The following line raises: AttributeError: can't set attribute
# Alpha.token = 2

This achieves the intended behavior. The attribute is accessible in a property-like way from both the class and its instances, and any attempt to rebind it on the class is rejected.

Why this matters

If your code relied on stacking @classmethod and @property, upgrading to Python 3.13 will stop it from working. The approach above avoids the decorator-chain trap that inspection cannot represent as a property, keeps the access pattern consistent across class and instance, and ensures no silent rebinding happens at the class level.

Takeaways

Don’t chain @classmethod and @property—this path is deprecated and disabled. Prefer an explicit descriptor that reads from the class, and pair it with a metaclass that denies writes to those attributes. This small amount of structure buys readable, predictable behavior that survives modern Python versions and eliminates the confusing edge cases of earlier workarounds.