Managed Attributes
|
This section documents the current Python release line as published at the official Python documentation, which is the reference these pages are written and verified against. No specific patch version is pinned. This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
A managed attribute looks like a plain field from the outside (obj.value) but runs code on access,
assignment, or deletion. Python gives you three layers to build one: @property for the common case, the
descriptor protocol it is built on, and the _getattr_/_getattribute_ hooks for whole-object
attribute interception.
@property: Computed and Validated Attributes
@property turns a method into an attribute-like accessor, so callers write obj.value instead of
obj.get_value() while the class still runs code on every read. Reference:
the property() built-in.
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
return self._radius
@property
def area(self):
return 3.14159 * self._radius ** 2 # computed on every read, never stored
c = Circle(2)
print(c.radius) # 2 -- looks like a plain attribute
print(c.area) # 12.56636 -- computed, not stored
Setters and Deleters
A bare @property is read-only — assigning to it raises AttributeError. @<name>.setter and
@<name>.deleter add the missing operations, letting a setter validate before storing:
class Circle:
def __init__(self, radius):
self.radius = radius # goes through the setter below, validated from construction on
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value <= 0:
raise ValueError("radius must be positive")
self._radius = value
@radius.deleter
def radius(self):
del self._radius
c = Circle(3)
c.radius = 5 # runs the setter's validation
# c.radius = -1 # raises ValueError: radius must be positive
del c.radius # runs the deleter
The setter and deleter are attached with @radius.setter/@radius.deleter — decorating a second and
third method that reuses the property’s name, which is why all three defer to a single private
self._radius for storage.
Descriptors: the Protocol Behind @property
@property is not special-cased in the interpreter — it is an ordinary class implementing the
descriptor protocol: any object defining _get_, _set_, or _delete_ and assigned as a
class attribute intercepts attribute access on every instance. Full walkthrough, including how property
itself is implemented in pure Python: the Descriptor HowTo
Guide.
class PositiveNumber:
def __set_name__(self, owner, name):
self._name = "_" + name # remembers which attribute it was assigned to
def __get__(self, instance, owner):
if instance is None:
return self # accessed on the class itself, e.g. Circle.radius
return instance.__dict__[self._name]
def __set__(self, instance, value):
if value <= 0:
raise ValueError(f"{self._name[1:]} must be positive")
instance.__dict__[self._name] = value
def __delete__(self, instance):
del instance.__dict__[self._name]
class Circle:
radius = PositiveNumber() # descriptor instance, assigned as a CLASS attribute
def __init__(self, radius):
self.radius = radius # triggers PositiveNumber.__set__
c = Circle(3)
print(c.radius) # 3 -- triggers PositiveNumber.__get__
c.radius = 5 # triggers PositiveNumber.__set__
# c.radius = -1 # raises ValueError: radius must be positive
A descriptor defining _set_ or _delete_ is a data descriptor and takes priority over an
instance’s own dict; one defining only _get_ is a non-data descriptor and an instance
attribute of the same name wins instead. @property always defines all three methods, so it is always a
data descriptor — which is why assigning self.radius = … inside _init_ still goes through its
setter rather than silently creating a shadowing instance attribute.
_getattr_ vs. _getattribute_
Both hooks intercept obj.name, but at different points in the lookup, per
the data model reference:
-
_getattribute_runs for every attribute access, unconditionally — normal lookup (instancedict, then class, then descriptors) is whatever the default implementation does internally. Overriding it means reimplementing that lookup yourself for every attribute, including ones you do not care about. -
_getattr_runs only as a fallback, after normal lookup has already failed withAttributeError— it never sees an attribute that was found normally, so it only needs to handle the missing case.
Validating with _getattr_ (the common case)
Use _getattr_ to compute or validate attributes that do not otherwise exist, without touching lookup
for anything that does:
class Config:
def __init__(self):
self.debug = True # a real attribute; __getattr__ never sees it
def __getattr__(self, name):
if name.startswith("_"):
raise AttributeError(name)
return f"<no setting '{name}'>" # fallback for any other missing attribute
cfg = Config()
print(cfg.debug) # True -- normal lookup, __getattr__ not called
print(cfg.timeout) # <no setting 'timeout'> -- lookup failed, __getattr__ called
Validating with _getattribute_ (intercepting everything)
Use _getattribute_ only when every access — existing attributes included — must be checked, and
always delegate to the base implementation to keep normal lookup working:
class Locked:
def __init__(self):
self.value = 42
def __getattribute__(self, name):
if name == "value":
print(f"reading {name!r}")
return super().__getattribute__(name) # required: performs the actual lookup
obj = Locked()
print(obj.value) # prints "reading 'value'", then 42 -- runs even though 'value' exists
Forgetting super().getattribute(name) and instead writing self.name inside _getattribute_
recurses infinitely, since reading self.name calls _getattribute_ again.
See Also
-
Classes and Objects — classes, instances, and
dict, the foundation@propertyand descriptors build on. -
Decorators and Metaclasses —
@propertyis itself a decorator; metaclasses are the other main hook into attribute and class creation.