34  Dynamic Attributes and Properties

NoteCore idea

Properties and dynamic attributes let you start with simple public attributes and add behavior later without breaking the API. This is the “uniform access principle” in Python.

In this chapter you will learn to:

  1. Use __getattr__ to compute attributes on demand.
  2. Wrap a JSON-like dict so callers can write obj.key.sub instead of obj["key"]["sub"].
  3. Add validation with @property and the setter/deleter decorators.
  4. Build a property factory when the same logic applies to multiple attributes.
  5. Cache an expensive computation with functools.cached_property.
  6. Read the contract of __getattr__, __setattr__, __delattr__, and __getattribute__.

34.1 Data wrangling with __getattr__

You’re reading a JSON feed and the structure is nested four or five levels deep. Writing feed["schedule"]["speakers"][0]["name"] everywhere is noisy — every ["..."] is visual clutter. You want feed.schedule.speakers[0].name instead, but the data is a plain dict you don’t control. __getattr__ is the hook: it’s called only when normal attribute lookup fails, which means you can intercept any unknown attribute and look it up in the underlying dict.

class FrozenJSON:
    """Read-only attribute access over a nested dict/list."""

    def __new__(cls, arg):
        if isinstance(arg, dict):
            return super().__new__(cls)
        elif isinstance(arg, list):
            return [cls(item) for item in arg]
        else:
            return arg

    def __init__(self, mapping):
        self.__data = dict(mapping)

    def __getattr__(self, name):
        try:
            return getattr(self.__data, name)
        except AttributeError:
            return FrozenJSON(self.__data[name])

raw = {
    "schedule": {
        "speakers": [
            {"name": "Cory Doctorow", "topic": "Surveillance Capitalism"},
            {"name": "Brian Smith", "topic": "Inside Python"},
        ],
        "venue": "auditorium",
    },
}
feed = FrozenJSON(raw)
feed.schedule.speakers[0].name, feed.schedule.speakers[-1].name, feed.schedule.venue
('Cory Doctorow', 'Brian Smith', 'auditorium')

Walking through this piece by piece:

  • __new__(cls, arg) runs before __init__ and gets to choose what kind of object actually comes back. If the argument is a dict, we hand off to the normal flow with super().__new__(cls). If it’s a list, we recursively wrap each item — the caller gets a list of FrozenJSON instances, not a FrozenJSON. If it’s anything else (string, int, None), we just return it unchanged. That’s how a deeply nested structure gets wrapped uniformly.
  • __init__(self, mapping) stores the underlying dict as self.__data. The double underscore triggers name mangling — outside the class it’s accessible as _FrozenJSON__data, so subclasses can’t accidentally clobber it.
  • __getattr__(self, name) is the hook. Python only calls it when normal attribute lookup fails, so it doesn’t intercept everything — feed.__data still works normally because that attribute exists.
  • Inside __getattr__ we first try getattr(self.__data, name) — that’s how dict methods like .keys() and .items() keep working on the wrapper. Only if the dict has no such method does it fall through to self.__data[name], wrapping the result in a fresh FrozenJSON so the recursion continues one level deeper.

The general pattern: __getattr__ is the fallback Python consults after every normal lookup path has failed, so it’s safe to use as a “computed attribute” hook without intercepting your real attributes.

34.2 @property for validation

Plain public attributes are fine until you need a check. The lovely thing about @property is that callers don’t have to change a single line — obj.weight = -1 still looks like an attribute assignment, but it now runs through your setter:

class LineItem:
    def __init__(self, description, weight, price):
        self.description = description
        self.weight = weight    # uses the setter
        self.price = price

    def subtotal(self):
        return self.weight * self.price

    @property
    def weight(self):
        return self.__weight

    @weight.setter
    def weight(self, value):
        if value > 0:
            self.__weight = value
        else:
            raise ValueError("weight must be > 0")

    @property
    def price(self):
        return self.__price

    @price.setter
    def price(self, value):
        if value > 0:
            self.__price = value
        else:
            raise ValueError("price must be > 0")

raisins = LineItem("Golden raisins", 10, 6.95)
raisins.subtotal()
69.5
raisins.weight = 0
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[3], line 1
----> 1 raisins.weight = 0

Cell In[2], line 19, in LineItem.weight(self, value)
     15     def weight(self, value):
     16         if value > 0:
     17             self.__weight = value
     18         else:
---> 19             raise ValueError("weight must be > 0")

ValueError: weight must be > 0

Walking through the moving parts:

  • The first weight method (decorated @property) is the getter — it returns self.__weight, the name-mangled internal storage. Reading raisins.weight calls this.
  • @weight.setter registers the setter — called on every assignment to raisins.weight. It checks value > 0, and only then writes to self.__weight. Writing self.weight = ... here would recurse forever, since that goes through the setter again.
  • __init__ does self.weight = weight — that goes through the setter too, so a bad initial value is rejected at construction, not later.
  • price repeats the same pattern. The duplication is real — the next section shows how to factor it out.

The general pattern: a @property getter/setter pair lets you replace a plain attribute with method-backed behavior — validation, logging, computed values — without changing a single caller’s syntax. @x.deleter is the third piece if you need to intercept del obj.x, but you’ll rarely reach for it.

34.3 A property factory

When two attributes need the same validation, the property declarations duplicate. Build a factory that returns a property:

def quantity(storage_name):
    def qty_getter(instance):
        return instance.__dict__[storage_name]
    def qty_setter(instance, value):
        if value > 0:
            instance.__dict__[storage_name] = value
        else:
            raise ValueError(f"{storage_name} must be > 0")
    return property(qty_getter, qty_setter)

class LineItemF:
    weight = quantity("weight")
    price = quantity("price")

    def __init__(self, description, weight, price):
        self.description = description
        self.weight = weight
        self.price = price
    def subtotal(self):
        return self.weight * self.price

LineItemF("apples", 5, 2).subtotal()
10
LineItemF("bad", -1, 2)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[5], line 1
----> 1 LineItemF("bad", -1, 2)

Cell In[4], line 17, in LineItemF.__init__(self, description, weight, price)
     15     def __init__(self, description, weight, price):
     16         self.description = description
---> 17         self.weight = weight
     18         self.price = price

Cell In[4], line 8, in quantity.<locals>.qty_setter(instance, value)
      4     def qty_setter(instance, value):
      5         if value > 0:
      6             instance.__dict__[storage_name] = value
      7         else:
----> 8             raise ValueError(f"{storage_name} must be > 0")

ValueError: weight must be > 0

Walking through the factory:

  • quantity(storage_name) is a function that returns a property object. The storage_name argument is captured in a closure — each property knows which key in the instance dict to read and write.
  • qty_getter(instance) reads instance.__dict__[storage_name] directly. Note instance is what @property calls self — the actual object the access is happening on.
  • qty_setter(instance, value) does the same > 0 check as before, raising with a message that includes the attribute name so the error tells you which one was bad.
  • property(qty_getter, qty_setter) builds the property, equivalent to writing @property and @x.setter by hand.
  • In the class body, weight = quantity("weight") and price = quantity("price") install the property as a class attribute — same effect as the decorator form, but expressed with one call per attribute.

The general pattern: when the same property logic repeats across several attributes, lift it into a factory that returns the property — one line per attribute instead of two methods each. The factory is the entry point to descriptor land — see Chapter 35. A property is a descriptor, and the factory is one way to write one without the protocol boilerplate.

34.4 cached_property

When the computation is expensive but the inputs don’t change, functools.cached_property saves the result on the instance dict the first time it’s accessed:

from functools import cached_property
import math

class Circle:
    def __init__(self, radius):
        self.radius = radius

    @cached_property
    def area(self):
        print("computing")
        return math.pi * self.radius ** 2

c = Circle(5)
c.area, c.area
computing
(78.53981633974483, 78.53981633974483)

Walking through what happened:

  • @cached_property decorates area like a regular @property — but on first access it stores the computed value in the instance’s __dict__ under the same name (area).
  • The first c.area ran the function — that’s the "computing" line. It computed math.pi * 25 and stashed it in c.__dict__["area"].
  • The second c.area did not print. Because cached_property is a non-overriding descriptor (it has __get__ but no __set__), the instance dict shadows it on subsequent reads — see Section 35.3.
  • The cache lives until you del c.area or assign c.area = ... to overwrite it; it’s not invalidated when self.radius changes. Use it only when the inputs are effectively immutable.

The general guidance: reach for cached_property when the value is expensive to compute and you’d otherwise memoize it by hand. It requires the instance to have a writable __dict__, so it’s incompatible with __slots__.

34.5 The four attribute hooks

Before reaching for the hooks, here is what obj.x actually walks through. Most of the time you only customize the bottom step — __getattr__:

flowchart TB
    A["obj.x"] --> B["__getattribute__"]
    B --> C{"data descriptor<br/>on the class?"}
    C -- yes --> Z["call descriptor.__get__"]
    C -- no --> D{"x in instance __dict__?"}
    D -- yes --> R["return value"]
    D -- no --> E{"non-data descriptor<br/>or plain class attribute?"}
    E -- yes --> Z
    E -- no --> F["__getattr__"]
    F -- still nothing --> X["AttributeError"]

Python has four customization hooks for attribute access:

Method When called
__getattribute__(self, name) every access (obj.x); rarely override
__getattr__(self, name) only when normal lookup fails
__setattr__(self, name, value) every assignment (obj.x = ...)
__delattr__(self, name) every deletion (del obj.x)

__getattribute__ is dangerous to override — it’s called for every attribute access, including the ones inside the override itself. __getattr__ is the safer 99% choice.

__setattr__ traps every assignment. The classic infinite-recursion bug:

class Bad:
    def __setattr__(self, name, value):
        self.name = value      # ← calls __setattr__ again, recursively

The fix is to bypass the hook by writing to __dict__ directly, or by calling super().__setattr__:

class Logging:
    def __setattr__(self, name, value):
        print(f"{name} = {value!r}")
        super().__setattr__(name, value)

obj = Logging()
obj.x = 42
obj.x
x = 42
42

Walking through this:

  • __setattr__(self, name, value) is called on every assignment — obj.x = 42 ends up here as self.__setattr__("x", 42).
  • The print line logs the assignment. Then super().__setattr__(name, value) delegates to object.__setattr__, which performs the actual write to self.__dict__. Without that delegation the value would never be stored.
  • Reading obj.x doesn’t go through __setattr__ at all — assignment and access are separate hooks.

The general pattern: when you override __setattr__, always end with super().__setattr__(name, value) (or write to self.__dict__ directly). Self-assignment inside the method is the classic infinite-recursion bug.

The free helpers are worth knowing:

  • getattr(obj, name, default) — safe attribute access; returns default instead of raising.
  • hasattr(obj, name)True if getattr would not raise.
  • setattr(obj, name, value) — programmatic assignment.
  • delattr(obj, name) — programmatic deletion.
  • vars(obj) — returns obj.__dict__.
TipWhy this matters

Start with plain public attributes. If you later need validation or computed values, add a @property — callers don’t change their code. This is Python’s answer to getters/setters: they’re unnecessary at first, and available when needed, without breaking the API.

34.6 Build: a Person class with validated fields, a cached derivation, and a metadata fallback

A modest data class that exercises each chapter tool: a property factory for repeated validation, cached_property for a value computed once per instance, and __getattr__ for transparent fallback to a metadata dict.

Step 1: a property factory for non-negative fields. age and height should both reject negatives — write the validator once and apply it twice:

def non_negative(name):
    def getter(self):
        return self.__dict__[f"_{name}"]
    def setter(self, value):
        if value < 0:
            raise ValueError(f"{name} must be non-negative, got {value!r}")
        self.__dict__[f"_{name}"] = value
    return property(getter, setter)

class Person:
    age = non_negative("age")
    height_cm = non_negative("height_cm")

    def __init__(self, name: str, age: int, height_cm: float) -> None:
        self.name = name
        self.age = age                     # uses the setter — validates
        self.height_cm = height_cm

p = Person("Alice", 30, 165)
[p.name, p.age, p.height_cm]
['Alice', 30, 165]
p.age = -1
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[9], line 1
----> 1 p.age = -1

Cell In[8], line 6, in non_negative.<locals>.setter(self, value)
      4     def setter(self, value):
      5         if value < 0:
----> 6             raise ValueError(f"{name} must be non-negative, got {value!r}")
      7         self.__dict__[f"_{name}"] = value

ValueError: age must be non-negative, got -1

non_negative(name) is a closure-based factory: each call captures the storage name and returns a fresh property. The class body uses it twice as a class attribute — same effect as writing @property and @x.setter per field, but one line per attribute instead of two methods. The validator runs in __init__ too, so a bad starting value is rejected at construction.

Step 2: a cached_property for an expensive-ish derivation. Compute the BMI once per instance and store it on the instance dict. Accessing p.bmi repeatedly only runs the computation the first time:

from functools import cached_property

class Person(Person):                    # extend the previous class
    @cached_property
    def bmi(self) -> float:
        print("computing BMI")           # observe the one-time call
        height_m = self.height_cm / 100
        return round(70 / (height_m ** 2), 2)   # toy formula

p = Person("Alice", 30, 165)
[p.bmi, p.bmi, p.bmi]                    # only one "computing" line
computing BMI
[25.71, 25.71, 25.71]

cached_property runs the body the first time p.bmi is accessed, stores the result in p.__dict__["bmi"], and never runs again. The print fires once; subsequent reads hit the dict directly. Caveat: the cache isn’t invalidated when height_cm changes — cached_property is for values that are effectively immutable per instance, not for live derivations.

Step 3: a __getattr__ fallback for arbitrary metadata. Allow callers to attach extra information that’s accessible like an attribute, with the validated fields still taking precedence:

class Person(Person):
    def __init__(self, name, age, height_cm, **metadata):
        super().__init__(name, age, height_cm)
        self._metadata = metadata

    def __getattr__(self, name):
        # Called *only* when normal lookup fails — so age/height_cm/bmi never reach here
        if name.startswith("_"):
            raise AttributeError(name)
        meta = self.__dict__.get("_metadata", {})
        if name in meta:
            return meta[name]
        raise AttributeError(f"{type(self).__name__!r} has no attribute {name!r}")

p = Person("Alice", 30, 165, city="London", role="engineer")
[p.name, p.age, p.bmi, p.city, p.role]
computing BMI
['Alice', 30, 25.71, 'London', 'engineer']
p.unknown
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[12], line 1
----> 1 p.unknown

Cell In[11], line 13, in Person.__getattr__(self, name)
      9             raise AttributeError(name)
     10         meta = self.__dict__.get("_metadata", {})
     11         if name in meta:
     12             return meta[name]
---> 13         raise AttributeError(f"{type(self).__name__!r} has no attribute {name!r}")

AttributeError: 'Person' has no attribute 'unknown'

__getattr__ only fires after the normal attribute machinery has failed — so the validated age/height_cm/bmi are still found first. The name.startswith("_") guard is safety: dunder lookups (__copy__, __deepcopy__, __getstate__, …) hit __getattr__ for any attribute Python is probing for during pickling/copy/etc.; raising plain AttributeError for those is the right contract. The unknown-attribute case ends with a useful AttributeError message — getattr(p, 'role', 'default') would treat it correctly as “absent” and return 'default'.

The build threads the chapter together: a property factory for repeated validation logic (Step 1), cached_property for a per-instance derivation (Step 2), and __getattr__ for a transparent dict fallback that respects the rest of the attribute machinery (Step 3). About thirty lines of code; behaves like a much more elaborate framework class.

34.7 Exercises

  1. FrozenJSON for lists at top level. Modify FrozenJSON so it works when the top-level argument is a list. (The __new__ already handles it — verify.)

  2. @property deleter. Add @weight.deleter to LineItem so del item.weight raises a custom error. Test it.

  3. Property factory + types. Extend quantity to take an optional expected_type. Reject values that aren’t instances of that type with a clear message.

  4. cached_property and __slots__. Try adding __slots__ to Circle. What error do you get? Why?

  5. Read the chain. What does obj.x = 1 actually do, step by step? Walk through __setattr__, the descriptor protocol, and the __dict__ write.

34.8 Summary

Dynamic attributes are the entry point to the descriptor protocol that powers all of Python’s attribute access. __getattr__ and @property cover almost every real-world case; cached_property is the one extension you’ll reach for routinely. Use __getattribute__ only when nothing else works.

Next, Chapter 35 goes one level deeper: descriptors are the mechanism behind @property, @classmethod, and methods themselves — and the right tool when the same validation logic applies to many attributes or many classes.