Python's Descriptor Protocol: How Properties, Methods, and Slots Work Under the Hood
Discover how Python's descriptor protocol powers properties, methods, and __slots__ behind the scenes. This guide explains data vs. non-data descriptors, the attribute lookup chain, and why understanding this mechanism makes you a stronger Python developer.
Advertisement
Let me guess—you've used @property to control attribute access, called methods on objects, and maybe even used __slots__ to save memory. But have you ever wondered what's actually happening under the hood? I remember when I first started digging into Python's internals at PythonSkillset, I discovered something fascinating: all these features are built on the same mechanism—the descriptor protocol.
It's like finding out that three seemingly different tools in your toolbox are actually just variations of the same clever design. And once you understand it, you'll never look at attribute access the same way again.
What's a Descriptor Anyway?
In simple terms, a descriptor is an object that defines how another object's attributes are accessed or set. It's any object that implements any of these methods:
- __get__
- __set__
- __delete__
But here's the mind-blowing part: when you access an attribute on a class or instance, Python doesn't just look up the value in a dictionary. It first checks if there's a descriptor object hiding behind that attribute name. If there is, Python hands control over to the descriptor's methods.
Let me show you what I mean:
class Descriptor:
def __get__(self, obj, objtype=None):
return f"Accessed via descriptor, obj is {obj}"
class MyClass:
attr = Descriptor()
obj = MyClass()
print(obj.attr) # Output: Accessed via descriptor, obj is <__main__.MyClass object at ...>
When you wrote obj.attr, Python didn't return some simple value. It called Descriptor.__get__, passing in the instance obj and the class MyClass. You're literally handing control to the descriptor.
How @property Works
Now you'll understand why this code works:
class Person:
def __init__(self, name):
self._name = name
@property
def name(self):
return self._name
@name.setter
def name(self, value):
if not isinstance(value, str):
raise TypeError("Name must be a string")
self._name = value
The @property decorator creates what's called a "property object"—a pre-built descriptor that PythonSkillset's own documentation calls "a managed attribute." When you access person.name, Python sees that name is a property object on the class, not a simple value. It calls the property's __get__ method, which executes your getter function. The @name.setter adds a setter to the same property object.
This is why you can't accidentally do person.name = 42 without it throwing an error—the descriptor intercepts the assignment and runs your validation code.
Methods Are Descriptors Too
Here's something that surprised me: every function you define inside a class becomes a descriptor automatically. Let's verify this:
class Calculator:
def add(self, x, y):
return x + y
print(type(Calculator.add)) # Look closely at this
In Python 3, that prints <class 'function'>. But functions are special—they implement __get__. So when you do calc.add, Python calls Calculator.add.__get__(calc, Calculator), and that returns a bound method object.
Watch what happens manually:
calc = Calculator()
bound_add = Calculator.add.__get__(calc, Calculator)
print(bound_add) # <bound method Calculator.add of <__main__.Calculator object at ...>>
print(bound_add(2, 3)) # 5
The function's __get__ method wraps self into the call, creating the bound method you're used to seeing. When you call calc.add(2, 3), Python actually does: Calculator.add.__get__(calc, Calculator).__call__(2, 3).
slots and Memory Efficiency
This one's practical. Every Python object has a __dict__ by default—a dictionary that holds all its instance attributes. Dictionaries are memory-heavy. If you're creating millions of objects, that's a problem.
Enter __slots__:
class Point:
__slots__ = ('x', 'y')
def __init__(self, x, y):
self.x = x
self.y = y
Now Point objects don't have a __dict__. Instead, Python creates descriptors automatically for x and y during class creation. These descriptors know exactly where in memory each attribute lives—like direct offsets.
But here's the catch: each descriptor has its own storage scheme. The slot descriptors store values directly on the instance's memory block, not in a dictionary. That's why __slots__ saves memory but also limits flexibility—you can't add attributes not in __slots__.
Data vs Non-Data Descriptors
Here's where things get interesting. There are two types of descriptors:
Data descriptors define both __get__ and __set__ (or __delete__). They take priority over instance attributes.
Non-data descriptors only define __get__. They're overridden by instance attributes in the lookup chain.
Check this out:
class DataDescriptor:
def __get__(self, obj, objtype):
return "data"
def __set__(self, obj, value):
pass
class NonDataDescriptor:
def __get__(self, obj, objtype):
return "non-data"
class Demo:
data = DataDescriptor()
non = NonDataDescriptor()
demo = Demo()
demo.instance_data = "instance value"
demo.instance_non = "instance value"
print(demo.data) # "data" - class descriptor wins
print(demo.non) # "instance value" - instance attribute overrides
Why does this matter? Properties are data descriptors (they have get and set). Methods are non-data descriptors (only get). That's why you can override methods on an instance:
class Greeter:
def hello(self):
return "Hello"
greeter = Greeter()
greeter.hello = lambda: "Bonjour"
print(greeter.hello()) # "Bonjour" - instance overrides non-data descriptor
Putting It All Together
When you write obj.attribute in Python, here's what actually happens:
- Python calls
type(obj).__getattribute__(obj, "attribute") - It first checks the type's MRO (method resolution order) for data descriptors
- If found, calls the descriptor's
__get__ - If not, checks
obj.__dict__for instance attributes - If still not found, checks the type's MRO for non-data descriptors
- If nothing found, raises
AttributeError
This means with @property, the descriptor always wins—even if there's an instance attribute with the same name. With methods, instance attributes take precedence, which is why this works:
class Container:
def items(self):
return [1, 2, 3]
c = Container()
c.items = [4, 5, 6] # Shadows the method
print(c.items) # [4, 5, 6]
Why Should You Care?
You might think "I'll just use properties and methods without knowing this," and you'd be mostly right. But understanding descriptors helps you:
- Write clean custom properties without boilerplate
- Debug unexpected behavior when attributes don't act as expected
- Design frameworks and APIs that feel natural to Python users
- Optimize memory for large object collections
- Understand how Python's internals actually work
The next time you use @property, decorate a method, or define __slots__, you'll know you're using the same elegant mechanism Python uses everywhere. It's like learning the grammar behind a language you already speak—it doesn't change what you say, but it helps you understand why you say it that way.
Advertisement
Comments
Questions, corrections, and tips stay visible for everyone reading this page.
Join the discussion
No comments yet
Be the first to leave a note — it helps the next reader.