Skip to content

Descriptors and Properties

Descriptors are the mechanism behind property``classmethod``staticmethodAnd the entire Attribute access system in Python. A descriptor is any object that implements at least one of __get__``__set__Or __delete__.

TypeMethodsBehavior
Non-data descriptor__get__ onlyInstance attribute takes precedence
Data descriptor__get__ + __set__ or __delete__Descriptor always wins over instance attribute

This distinction is fundamental to understanding Python”s attribute lookup.

class NonDataDescriptor:
def __init__(self, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return f"NonDataDescriptor for {self.name}"
class DataDescriptor:
def __init__(self, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return f"DataDescriptor for {self.name}"
def __set__(self, obj, value):
print(f"Setting {self.name} = {value}")
class Example:
non_data = NonDataDescriptor("non_data")
data = DataDescriptor("data")
e = Example()
print(e.non_data) # NonDataDescriptor for non_data
print(e.data) # DataDescriptor for data
## Instance attribute overrides non-data descriptor
e.non_data = "override"
print(e.non_data) # override
## Data descriptor always wins
e.data = "override" # Setting data = override
print(e.data) # DataDescriptor for data (descriptor wins)

When you access obj.attrPython follows this order:

  1. type(obj).__mro__. Search the MRO for a data descriptor with name attr.
  2. obj.__dict__. Check the instance dictionary.
  3. type(obj).__mro__. Search the MRO for a non-data descriptor.
  4. Raise AttributeError if nothing is found.
class LookupDemo:
"""Demonstrates attribute lookup order."""
class DataDesc:
def __get__(self, obj, objtype=None):
return "data descriptor"
def __set__(self, obj, value):
pass
class NonDataDesc:
def __get__(self, obj, objtype=None):
return "non-data descriptor"
data_desc = DataDesc()
non_data_desc = NonDataDesc()
d = LookupDemo()
d.__dict__["data_desc"] = "instance value"
d.__dict__["non_data_desc"] = "instance value"
print(d.data_desc) # data descriptor (step 1 wins)
print(d.non_data_desc) # instance value (step 2 wins over step 3)

property is a built-in data descriptor that wraps getter, setter, and deleter methods:

class Server:
def __init__(self, host, port):
self._host = host
self._port = port
@property
def address(self):
return f"{self._host}:{self._port}"
@address.setter
def address(self, value):
host, port = value.rsplit(":", 1)
self._host = host
self._port = int(port)
@address.deleter
def address(self):
self._host = None
self._port = None
s = Server("localhost", 8080)
print(s.address) # localhost:8080
s.address = "0.0.0.0:9090"
print(s.address) # 0.0.0.0:9090
del s.address

property is implemented as a data descriptor. Here is a simplified version:

class Property:
def __init__(self, fget=None, fset=None, fdel=None, doc=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
self.__doc__ = doc
def __get__(self, obj, objtype=None):
if obj is None:
return self
if self.fget is None:
raise AttributeError("unreadable attribute")
return self.fget(obj)
def __set__(self, obj, value):
if self.fset is None:
raise AttributeError("can't set attribute")
self.fset(obj, value)
def __delete__(self, obj):
if self.fdel is None:
raise AttributeError("can't delete attribute")
self.fdel(obj)
def setter(self, fset):
self.fset = fset
return self
def deleter(self, fdel):
self.fdel = fdel
return self

functools.cached_property (Python 3.8+) caches the result of a property on the instance. It is a Non-data descriptor:

from functools import cached_property
import time
class ExpensiveComputation:
def __init__(self, data):
self.data = data
@cached_property
def result(self):
"""Expensive computation cached after first access."""
time.sleep(1) # Simulate work
return sum(self.data)
e = ExpensiveComputation(range(1000000))
t0 = time.time()
print(e.result) # First call: ~1 second
t1 = time.time()
print(e.result) # Second call: instant (cached)
t2 = time.time()
print(f"First: {t1-t0:.3f}s, Second: {t2-t1:.3f}s")
## classmethod and staticmethod

Both are implemented as descriptors.

classmethod transforms a method so it receives the class as its first argument instead of the Instance:

class Database:
_instance = None
def __init__(self, host, port):
self.host = host
self.port = port
@classmethod
def from_env(cls):
import os
host = os.getenv("DB_HOST", "localhost")
port = int(os.getenv("DB_PORT", "5432"))
return cls(host, port)
@classmethod
def from_config(cls, config_dict):
return cls(config_dict["host"], config_dict["port"])
# Both return a Database instance
db1 = Database.from_env()
db2 = Database.from_config({"host": "db.example.com", "port": 3306})

classmethod is a non-data descriptor:

class ClassMethod:
def __init__(self, func):
self.func = func
def __get__(self, obj, objtype=None):
if objtype is None:
objtype = type(obj)
def bound_method(*args, **kwargs):
return self.func(objtype, *args, **kwargs)
return bound_method

staticmethod wraps a function so it receives no implicit first argument:

class MathUtils:
@staticmethod
def clamp(value, min_val, max_val):
return max(min_val, min(max_val, value))
@staticmethod
def is_prime(n):
if n < 2:
return False
for i in range(2, int(n**0.5) + 1):
if n % i == 0:
return False
return True
print(MathUtils.clamp(150, 0, 100)) # 100
print(MathUtils.is_prime(17)) # True

staticmethod is also a descriptor, but a simple one — it just returns the original function Without binding:

class StaticMethod:
def __init__(self, func):
self.func = func
def __get__(self, obj, objtype=None):
return self.func
## \_\_slots\_\_

__slots__ replaces the instance __dict__ with a fixed set of attribute descriptors, saving Memory and preventing dynamic attribute creation:

class Point:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(1, 2)
print(p.x, p.y) # 1 2
p.z = 3 # AttributeError: "Point'' object has no attribute "z'
import sys
class WithoutSlots:
def __init__(self, x, y):
self.x = x
self.y = y
class WithSlots:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
ws = WithoutSlots(1, 2)
wsl = WithSlots(1, 2)
print(sys.getsizeof(ws)) # 56 bytes (instance + __dict__)
print(sys.getsizeof(wsl)) # 48 bytes (instance only, no __dict__)
# The savings compound with many instances
ws_list = [WithoutSlots(i, i) for i in range(100000)]
wsl_list = [WithSlots(i, i) for i in range(100000)]

On CPython 3.12, a slotted instance with two attributes uses ~48 bytes versus ~56 bytes for a Regular instance (which includes the __dict__ overhead). The savings are more significant when you Have many instances.

  1. All parent classes must also use __slots__. If a parent has __dict__The child will too, negating the benefit.
  2. __slots__ is inherited. A child class gets the parent’s slots plus its own.
  3. You cannot add attributes not in __slots__. This is the whole point.
  4. __slots__ must contain strings. Not expressions.
  5. __dict__ and __weakref__ are special. You can add them to __slots__ to enable dynamic attributes or weak references.
class Base:
__slots__ = ("x",)
class Child(Base):
__slots__ = ("y",) # Inherits x from Base, adds y
c = Child()
c.x = 1
c.y = 2
# c.z = 3 # AttributeError
# To allow dynamic attributes AND slots:
class Flexible:
__slots__ = ("x", "__dict__")
f = Flexible()
f.x = 1
f.dynamic = "allowed" # Stored in __dict__
## \_\_getattr\_\_ and \_\_getattribute\_\_

These two methods control attribute access but have very different behaviors:

Called only when normal attribute lookup fails. This makes it a fallback mechanism:

class ConfigProxy:
def __init__(self, data):
self._data = data
def __getattr__(self, name):
if name.startswith("_"):
raise AttributeError(name)
try:
return self._data[name]
except KeyError:
raise AttributeError(f"Config has no key '{name}'")
config = ConfigProxy({"host": "localhost", "port": 8080})
print(config.host) # localhost
print(config.port) # 8080
print(config.missing) # AttributeError: Config has no key 'missing'

Called for every attribute access, unconditionally. This is dangerous and easy to get wrong:

class StrictAccess:
def __init__(self):
self._allowed = {"x", "y"}
def __getattribute__(self, name):
allowed = object.__getattribute__(self, "_allowed")
if name.startswith("_") or name in allowed:
return object.__getattribute__(self, name)
raise AttributeError(f"Access to '{name}' is not allowed")
s = StrictAccess()
print(s.x) # Works
# print(s.z) # AttributeError: Access to 'z' is not allowed
## \_\_setattr\_\_ and \_\_delattr\_\_

Called for every attribute assignment (obj.attr = value):

class ValidatedConfig:
def __setattr__(self, name, value):
if name.startswith("_"):
object.__setattr__(self, name, value)
return
if not isinstance(value, (str, int, float, bool)):
raise TypeError(f"Value for '{name}' must be a primitive type, got {type(value).__name__}")
object.__setattr__(self, name, value)
vc = ValidatedConfig()
vc.host = "localhost" # OK
vc.port = 8080 # OK
# vc.data = [1, 2, 3] # TypeError: Value for 'data' must be a primitive type

Called for every attribute deletion (del obj.attr):

class ProtectedAttributes:
PROTECTED = {"version", "build"}
def __delattr__(self, name):
if name in self.PROTECTED:
raise AttributeError(f"Cannot delete protected attribute '{name}'")
object.__delattr__(self, name)
pa = ProtectedAttributes()
pa.version = "1.0"
# del pa.version # AttributeError: Cannot delete protected attribute 'version'
## \_\_dir\_\_

The __dir__ method customizes the output of dir() and tab-completion in interactive shells:

class APIClient:
def __init__(self):
self._session = None
self._base_url = "https://api.example.com"
def __dir__(self):
public = [a for a in self.__dict__ if not a.startswith("_")]
public.extend([f for f in dir(type(self)) if not f.startswith("_") and callable(getattr(self, f))])
return sorted(public)
client = APIClient()
print(dir(client)) # Shows only public attributes and methods

functools: total_ordering and singledispatchmethod

Section titled “functools: total_ordering and singledispatchmethod”

@total_ordering fills in the remaining comparison methods when you define __eq__ and one of __lt__``__le__``__gt__Or __ge__:

from functools import total_ordering
@total_ordering
class Version:
def __init__(self, major, minor, patch):
self.major = major
self.minor = minor
self.patch = patch
def __eq__(self, other):
if not isinstance(other, Version):
return NotImplemented
return (self.major, self.minor, self.patch) == (other.major, other.minor, other.patch)
def __lt__(self, other):
if not isinstance(other, Version):
return NotImplemented
return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)
def __repr__(self):
return f"Version({self.major}, {self.minor}, {self.patch})"
v1 = Version(1, 2, 3)
v2 = Version(2, 0, 0)
print(v1 < v2) # True
print(v1 <= v2) # True (generated by total_ordering)
print(v1 > v2) # False (generated)
print(v1 >= v2) # False (generated)
### functools.singledispatchmethod

@singledispatchmethod (Python 3.8+) provides method overloading based on the type of the first Argument:

from functools import singledispatchmethod
class Serializer:
@singledispatchmethod
def serialize(self, value):
raise NotImplementedError(f"Cannot serialize {type(value).__name__}")
@serialize.register(str)
def _(self, value):
return value
@serialize.register(int)
def _(self, value):
return str(value)
@serialize.register(list)
def _(self, value):
return "[" + ", ".join(self.serialize(v) for v in value) + "]"
s = Serializer()
print(s.serialize("hello")) # hello
print(s.serialize(42)) # 42
print(s.serialize([1, "a"])) # [1, a]
class TypedField:
def __init__(self, name, expected_type):
self.name = name
self.expected_type = expected_type
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"Expected {self.expected_type.__name__} for '{self.name}', "
f"got {type(value).__name__}"
)
obj.__dict__[self.name] = value
class ServerConfig:
host = TypedField(str)
port = TypedField(int)
timeout = TypedField(float)
sc = ServerConfig()
sc.host = "0.0.0.0" # OK
sc.port = 8080 # OK
sc.timeout = 30.5 # OK
# sc.port = "8080" # TypeError: Expected int for 'port', got str
class LazyProperty:
def __init__(self, factory):
self.factory = factory
self.attrname = None
def __set_name__(self, owner, name):
self.attrname = f"_lazy_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
if not hasattr(obj, self.attrname):
setattr(obj, self.attrname, self.factory(obj))
return getattr(obj, self.attrname)
class DatabaseConnection:
def __init__(self, host):
self.host = host
@LazyProperty
def connection_pool(self):
print("Creating connection pool...")
return f"Pool connected to {self.host}"
db = DatabaseConnection("db.example.com")
print("Before first access")
print(db.connection_pool) # Creating connection pool... / Pool connected to db.example.com
print(db.connection_pool) # Pool connected to db.example.com (cached, no print)
class RangeValidator:
def __init__(self, min_val=None, max_val=None):
self.min_val = min_val
self.max_val = max_val
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if self.min_val is not None and value < self.min_val:
raise ValueError(f"{self.name} must be >= {self.min_val}, got {value}")
if self.max_val is not None and value > self.max_val:
raise ValueError(f"{self.name} must be <= {self.max_val}, got {value}")
obj.__dict__[self.name] = value
class ProcessConfig:
max_workers = RangeValidator(min_val=1, max_val=64)
timeout = RangeValidator(min_val=1, max_val=3600)
retry_count = RangeValidator(min_val=0, max_val=10)
pc = ProcessConfig()
pc.max_workers = 8 # OK
# pc.max_workers = 0 # ValueError: max_workers must be >= 1, got 0
# pc.timeout = 5000 # ValueError: timeout must be <= 3600, got 5000

Descriptors are the foundation of most Python ORM field implementations:

class Field:
def __init__(self, column_name=None, primary_key=False):
self.column_name = column_name
self.primary_key = primary_key
def __set_name__(self, owner, name):
self.name = name
self.column_name = self.column_name or name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj._data.get(self.column_name)
def __set__(self, obj, value):
obj._data[self.column_name] = value
class BaseModel:
_table_name = None
def __init__(self, **kwargs):
self._data = {}
for name, value in kwargs.items():
setattr(self, name, value)
@classmethod
def get_fields(cls):
return {name: attr for name, attr in cls.__dict__.items() if isinstance(attr, Field)}
class User(BaseModel):
_table_name = "users"
id = Field(primary_key=True)
username = Field()
email = Field()
is_active = Field()
user = User(id=1, username="admin", email="admin@example.com", is_active=True)
print(user.username) # admin
print(user.email) # admin@example.com
print(User.get_fields()) # Shows all Field instances
import logging
class LoggedAccess:
def __init__(self, log_level=logging.DEBUG):
self.log_level = log_level
self.name = None
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
value = obj.__dict__.get(self.name)
logging.log(self.log_level, f"Accessed {self.name} on {type(obj).__name__}")
return value
def __set__(self, obj, value):
logging.log(self.log_level, f"Setting {self.name} = {value!r} on {type(obj).__name__}")
obj.__dict__[self.name] = value
class Service:
state = LoggedAccess()
s = Service()
s.state = "running" # Logs: Setting state = 'running' on Service
_ = s.state # Logs: Accessed state on Service

Descriptors are the invisible machinery behind Python’s attribute access. When you write obj.attr, Python does not just look up a dictionary — it consults a priority list. Data descriptors (like property) always win over instance attributes, which is why you cannot bypass a property setter by assigning directly. Non-data descriptors (like regular functions) lose to instance attributes, which is why you can shadow a method with obj.method = lambda: 42. This hierarchy is the reason self exists in methods — the descriptor protocol inserts the instance as the first argument. Understanding descriptors is understanding how Python’s object model actually works under the hood.

class BadDescriptor:
def __init__(self, name):
self.name = name # This works only if you pass the name manually
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
class GoodDescriptor:
def __set_name__(self, owner, name):
self.name = name # Automatically called at class creation time
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.name)

Python 3.6+ calls __set_name__ automatically. Use it instead of passing names manually.

2. Data Descriptor Prevents Instance Attribute Assignment

Section titled “2. Data Descriptor Prevents Instance Attribute Assignment”
class AlwaysZero:
def __get__(self, obj, objtype=None):
return 0
def __set__(self, obj, value):
pass # Silently ignores assignment
class Example:
value = AlwaysZero()
e = Example()
e.value = 42
print(e.value) # 0 — the descriptor's __set__ ignores the assignment

If you need to allow override, make it a non-data descriptor (implement only __get__).

from functools import cached_property
import pickle
class Obj:
@cached_property
def computed(self):
return 42
obj = Obj()
_ = obj.computed
data = pickle.dumps(obj)
obj2 = pickle.loads(data)
# obj2.computed may not exist if the cached value wasn't pickled
# The descriptor recomputes on next access, which is in most cases fine
class A:
__slots__ = ("x",)
class B:
__slots__ = ("y",)
# class C(A, B): # TypeError: multiple bases have instance lay-out conflict
# pass
# Fix: use a common base or avoid multiple inheritance with __slots__
class Base:
__slots__ = ()
class A(Base):
__slots__ = ("x",)
class B(Base):
__slots__ = ("y",)
class C(A, B): # Works — both inherit from Base
__slots__ = ("z",)
class Bad:
def __getattribute__(self, name):
return self.__dict__[name] # RecursionError!
class Good:
def __getattribute__(self, name):
return object.__getattribute__(self, name) # Correct

Descriptors only work when defined on the class (or a base class). Assigning a descriptor to an Instance attribute does nothing special:

class Desc:
def __get__(self, obj, objtype=None):
return "descriptor"
class Example:
pass
Example.class_level = Desc() # Works as descriptor
e = Example()
e.instance_level = Desc() # Does NOT work as descriptor
print(e.class_level) # descriptor
print(e.instance_level) # <Desc object at 0x...> — just a regular object

7. Property Getter Returning None vs Not Set

Section titled “7. Property Getter Returning None vs Not Set”
class Tricky:
@property
def value(self):
return None # This is a valid return value
@value.setter
def value(self, v):
pass
t = Tricky()
print(t.value) # None — was it set to None or never set?
# There's no way to tell with property alone. Track state explicitly if needed.

This topic covers the core concepts of descriptors and properties, including underlying theory, practical implementation, and key applications.

Key concepts include:

  • relational databases and SQL
  • normalisation (1NF, 2NF, 3NF)
  • entity-relationship diagrams
  • transaction processing (ACID)
  • NoSQL and distributed databases

Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.

Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.

  • Classes: Shows how descriptors are used in class design, including properties, class methods, and static methods.
  • Metaclasses: Explores how metaclasses can manipulate descriptors during class creation.
  • Protocols and Dunder Methods: Explains the get, set, and delete dunder methods that define the descriptor protocol.
  • Data Validation: Demonstrates how to use descriptors to enforce validation rules on object attributes.