Skip to content

Descriptors and Properties | Python

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")

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__ 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__

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

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'

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)

@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
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.
flowchart TD
    A[Python Descriptors] --> B[Data Descriptor]
    A --> C[Non-data Descriptor]
    B --> D[__get__ + __set__ + __delete__]
    B --> E[Has priority over instance dict]
    C --> F[__get__ only]
    C --> G[Instance dict takes priority]
    H[Examples] --> I[property: data descriptor]
    H --> J[classmethod: non-data descriptor]
    H --> K[staticmethod: non-data descriptor]
    D --> M[Controls attribute access]
    D --> N[Used for validation]
    I --> R[@property decorator]

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.

Descriptors are objects that define __get__, __set__, or __delete__ methods, controlling how attributes are accessed on another object. Think of a descriptor as a security guard at a building entrance: it decides who gets in (read), who can modify things (write), and who gets removed (delete). Properties are the most common use case: they let you run code when an attribute is read or written, without changing the external API. Class methods and static methods are also descriptors under the hood.