Python Module 18 – Advanced Python: data model, metaprogramming and internals
Attribute lookup and the data model
How Python resolves obj.attr through descriptors, the instance dictionary and the class, when __getattr__ and __getattribute__ run, and how to build a proxy.
What you will learn
- Trace how Python resolves obj.attr through data descriptors, the instance dictionary, the class and __getattr__
- Predict what assignment and del do to an instance and to its class
- Use __getattr__ for missing names and __getattribute__ for every read, without infinite recursion
- Build a proxy object that forwards attribute access, special methods included
Before you start
What you’ll learn · Before you start
What you will learn
- Trace how Python resolves obj.attr through data descriptors, the instance dictionary, the class and __getattr__
- Predict what assignment and del do to an instance and to its class
- Use __getattr__ for missing names and __getattribute__ for every read, without infinite recursion
- Build a proxy object that forwards attribute access, special methods included
Before you start
On this page
A program writes 99 into an object’s own dictionary under the name value, and r.value still reads 21.5; it writes
'shadowed' there under the name of a method, and the method is gone. Both results come from attribute lookup, the
search Python runs for every obj.name. The default __getattribute__, inherited from object, looks first for a
data descriptor: A descriptor that defines __set__ or __delete__, such as a property or a slot. Attribute lookup checks it before the instance's dictionary, so an instance attribute of the same name can never hide it. More in “Descriptors: how properties and methods work” of that name on the class, such as a property, then in the instance’s
own dictionary, then for anything else of that name on the class, such as a method or a class attribute. Only when all
three come up empty does it raise AttributeError, and only then does Python call __getattr__. These rules belong to
Python’s data model, the part of the language reference that says which special method each piece of syntax calls.
Below you will step through that search on one object, watch assignment and del take a shorter path, build lazy
settings and a logging proxy, and reproduce the recursion that catches most first attempts at __getattribute__.
A read looks in four places, always in the same order
lookup_order.py gives a class one name of each kind: a property, a method, a plain class attribute, and a name that
only __getattr__ can answer. It reads all four, writes four keys straight into vars(r), past the property, and
reads them again:
class Reading:
unit = "celsius" # a plain class attribute
def __init__(self, value):
self._value = value
@property
def value(self): # a property: a data descriptor
return self._value
def label(self): # a function: a non-data descriptor
return f"{self.value} {self.unit}"
def __getattr__(self, name): # runs only when every other step fails
return f"<no {name}>"
r = Reading(21.5)
print("before:", r.value, "|", r.label(), "|", r.unit, "|", r.colour)
# Write straight into the instance's own dictionary, past any property:
vars(r)["value"] = 99
vars(r)["label"] = "shadowed"
vars(r)["unit"] = "kelvin"
vars(r)["colour"] = "red"
print("after: ", r.value, "|", r.label, "|", r.unit, "|", r.colour)
print("vars: ", vars(r)) Output
before: 21.5 | 21.5 celsius | celsius | <no colour>
after: 21.5 | shadowed | kelvin | red
vars: {'_value': 21.5, 'value': 99, 'label': 'shadowed', 'unit': 'kelvin', 'colour': 'red'}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lookup_order.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
- Before the instance’s dictionary is filled, every name is found where you would expect:
valuethrough the property,labelandunitin the class, andcolourby__getattr__, because no other step finds it. - Afterwards
r.valuestill gives 21.5. A property is a data descriptor, an object on the class with a__set__method, and data descriptors are checked before the instance’s dictionary. r.labelandr.unitnow give the instance’s own values. A function in the class is a non-data descriptor: A descriptor that defines only __get__, such as a function or functools.cached_property. An entry of the same name in the instance's dictionary hides it, which is how an instance attribute can shadow a method. More in “Descriptors: how properties and methods work”: its__get__method turns it into a bound method: What reading a method through an instance gives: the function together with that instance, so calling it later passes the instance in as self. c.step is a bound method; c.step(5) calls it. More in “Classes, objects and __init__”, but it has no__set__, so an instance attribute of the same name hides it. A plain class attribute is hidden in the same way.r.colourno longer reaches__getattr__. The name is found in the second step, and__getattr__runs only after every step has failed.
The whole search fits in one column of questions, each answered on the class or on the instance:
The four places Python looks when it reads obj.name
Text description of the diagram
The diagram is one column of questions, read from top to bottom. Each box says what happens when the answer is yes; a "no" leads to the next box.
- It starts at the expression obj.name.
- Step 1: is there a data descriptor called name on the class or one of its bases, such as a property or a slot? If yes, Python calls its __get__ method and uses the result, without looking at the instance's own dictionary.
- Step 2: is name a key of the instance's own dictionary, the one vars(obj) shows? If yes, that value is used.
- Step 3: is there anything else called name on the class or its bases, such as a method or a class attribute? If yes, it is used; a function found this way comes back as a bound method.
- Step 4: does the class define __getattr__? If yes, Python calls __getattr__(obj, name) and uses what it returns.
- If every answer is no, the read fails with AttributeError.
Steps 1 to 3 are the work of object.__getattribute__; step 4 runs only after it has raised AttributeError.
Now follow the eight reads of the run through it. The lit stage is the one that answers each read: watch the property
answer before the instance’s dictionary is consulted, and __getattr__ drop out once colour has a value of its own.
The drawing shows the whole scene; its 9 steps are listed below.
Every step as text
- r.value, before. The class holds value as a property, a data descriptor, so the first question answers at once. Python calls the property's getter, which returns 21.5.
- r.label, before. label is a function on the class. A function has __get__ but no __set__, so it is not a data descriptor, and vars(r) holds only _value. The third question finds the function and binds it to r.
- r.unit, before. unit is a plain class attribute. It is not a data descriptor and not in vars(r), so the third question finds it, and the read gives 'celsius'.
- r.colour, before. colour is on neither the class nor the instance. The default lookup raises AttributeError, and only then does Python call __getattr__(r, 'colour'), which returns '<no colour>'.
- vars(r) is filled. The program writes value, label, unit and colour straight into vars(r), past the property and past every hook. The instance's dictionary now has five keys.
- r.value, after. The first question still answers. The property is a data descriptor, so it wins before the 99 in vars(r) is looked at, and r.value gives 21.5 again.
- r.label, after. label is not a data descriptor, so the second question answers, with 'shadowed' from vars(r). The method on the class is hidden from this instance.
- r.unit, after. The same happens to unit. 'kelvin' in vars(r) hides the class attribute 'celsius'.
- r.colour, after. colour is found in vars(r), so the read gives 'red' and __getattr__ never runs. Python calls it only when every other place came up empty.
The first and third steps search the class and its bases in method resolution order (MRO): The order in which Python searches a class and its bases for an attribute, and the order super() follows. Class.__mro__ lists it, from the class itself to object. More in “Multiple inheritance, MRO and mixins”, the order
that super() follows, and the Descriptor Guide in the Python documentation writes the whole search out in about
twenty lines of pure Python. What a descriptor is, and how properties and methods use one, is the subject of the next
lesson. For lookup, one question decides: does the object found on the class define __set__ or __delete__? If it
does, it wins over the instance; if it does not, the instance wins.
Key idea
A data descriptor on the class beats the instance’s dictionary, the instance’s dictionary beats everything else on the
class, and __getattr__ runs only when all three have failed.
Checkpoint
Check: The order of a read
Writes and deletes take a shorter path
Assignment does not search the way a read does. It asks one question, whether the class has a data descriptor of that
name, and otherwise stores the value in the instance. Step through it on a Reading like the one above. own is
vars(r), so every store and every delete shows there:
Program · assign_trace.py
class Reading:
unit = "celsius"
def __init__(self, value):
self._value = value
@property
def value(self):
return self._value
r = Reading(21.5)
own = vars(r)
r.unit = "kelvin"
mine = r.unit
theirs = Reading.unit
del r.unit
back = r.unit
r.value = 30 Variables
- changed in this step
Printed so far
Error AttributeError: property 'value' of 'Reading' object has no setter Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.
The drawing shows the variables when the program ends, and the program's 10 steps are listed below.
Change the program and run it here. Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every step as text
- Start. No line has run yet, so there are no variables. Python starts with line 1.
- Line 1.
class Reading:makes the classReading. Its body runs only when it is used. - Line 12.
r = Reading(21.5)setsrto<Reading object>. - Line 13.
own = vars(r)setsownto{'_value': 21.5}.ownis the very dictionary thatvars(r)returns, so it shows every change to the attributesrkeeps itself. So far it holds_value, which__init__stored. - Line 14.
r.unit = "kelvin"rkeeps its value,<Reading object>. Key point The class has no data descriptor calledunit, soobject.__setattr__stored'kelvin'inown. Nothing on the class changed. - Line 15.
mine = r.unitsetsmineto'kelvin'. - Line 16.
theirs = Reading.unitsetstheirsto'celsius'. Read through the class,unitis still'celsius': an assignment through an instance never reaches the class. - Line 17.
del r.unitleavesras it was.del r.unitremoved the entry fromownand nothing else, soownholds_valuealone again. - Line 18.
back = r.unitsetsbackto'celsius'. With nounitof its own,r.unitfinds the class attribute again:'celsius'. - Line 19.
r.value = 30stops the program withAttributeError: property 'value' of 'Reading' object has no setter. Key pointvalueis a property, a data descriptor, so the assignment called its__set__instead of storing inown. This property has no setter, so it refused, andownnever saw avaluekey.
r.unit = "kelvin" landed in own, and Reading.unit kept 'celsius'. del r.unit removed only r’s entry, so the
class value showed through again. The last line stopped the program: a property takes over assignment for its name, and
this one has no setter. The longer example below repeats those steps and adds a class whose __setattr__ reports every
store:
class Reading:
unit = "celsius"
def __init__(self, value):
self._value = value
@property
def value(self):
return self._value
r = Reading(21.5)
r.unit = "kelvin" # stored in r's own dictionary
print(r.unit, Reading.unit) # the class keeps its value
del r.unit # removes r's own entry, nothing else
print(r.unit) # the class attribute shows through again
try:
r.value = 30 # the property has no setter
except AttributeError as e:
print("r.value = 30:", e)
try:
del r.unit # r has no unit of its own any more
except AttributeError as e:
print("del r.unit:", e)
class Audited:
"""Every assignment, those in __init__ included, passes through __setattr__."""
def __init__(self, owner):
self.owner = owner
self.balance = 0
def __setattr__(self, name, value):
print(f" set {name} = {value!r}")
super().__setattr__(name, value) # object.__setattr__ does the real store
a = Audited("Asha")
a.balance += 500
print(vars(a)) Output
kelvin celsius
celsius
r.value = 30: property 'value' of 'Reading' object has no setter
del r.unit: 'Reading' object has no attribute 'unit'
set owner = 'Asha'
set balance = 0
set balance = 500
{'owner': 'Asha', 'balance': 500}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 assign_paths.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
- The property’s refusal says why it refused:
property 'value' of 'Reading' object has no setter. delthrough an instance never deletes from the class: the seconddel r.unitfails althoughReading.unitexists.- A class can replace the store with
__setattr__, which runs for every assignment to its instances, those in__init__included. Augmented assignment calls it too:a.balance += 500reads the old value with a normal lookup and then calls__setattr__with the sum.
Assigning through self inside __setattr__
Inside __setattr__, writing self.balance = value calls __setattr__ again, and again, until the recursion limit
stops it with RecursionError. Store with super().__setattr__(name, value), which reaches object.__setattr__ and
does the real store.
__delattr__ works the same way for del: inside it, the real delete is super().__delattr__(name).
Checkpoint
Check: Writes and deletes
The fallback for missing names runs only after the lookup fails
__getattr__(self, name) is called only after the normal lookup has failed. It costs nothing for attributes that exist
and cannot hide them, which makes it the safe hook for names an object does not have yet, such as settings loaded on
first use. Step through a small version and watch own, the instance’s dictionary, and the count of loads:
Program · lazy_trace.py
class Settings:
def __init__(self, loaders):
self._load = loaders
self.loads = 0
def __getattr__(self, name):
if name not in self._load:
raise AttributeError(name)
self.loads += 1
value = self._load[name]()
setattr(self, name, value)
return value
s = Settings({"tax": lambda: 18})
own = vars(s)
first = s.tax
second = s.tax
loads = s.loads
smtp = hasattr(s, "smtp") Variables
- changed in this step
Printed so far
The program prints nothing.
Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.
The drawing shows the variables when the program ends, and the program's 8 steps are listed below.
Change the program and run it here. Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every step as text
- Start. No line has run yet, so there are no variables. Python starts with line 1.
- Line 1.
class Settings:makes the classSettings. Its body runs only when it is used. - Line 15.
s = Settings({"tax": lambda: 18})setssto<Settings object>. - Line 16.
own = vars(s)setsownto{'_load': {'tax': <lambda>()}, 'loads': 0}. - Line 17.
first = s.taxsetsfirstto18. Key pointtaxwas in neitherownnor the class, so the normal lookup failed and Python called__getattr__. It loaded 18 and stored it withsetattr():ownnow hastax, andloadsis 1. - Line 18.
second = s.taxsetssecondto18. This read foundtaxinown, the second place a read looks, so__getattr__never ran andloadsstayed 1. - Line 19.
loads = s.loadssetsloadsto1. - Line 20.
smtp = hasattr(s, "smtp")setssmtptoFalse. That was the last line, so the program ends.smtpis not a known section, so__getattr__raisedAttributeError, which is exactly whathasattr()turns intoFalse.
The first read of s.tax missed, so __getattr__ loaded the value and stored it with setattr(); the second read
found it in own, and loads stayed 1. The fuller version loads two sections and shows what goes wrong when the
fallback raises the wrong exception:
LOADERS = {
"database": lambda: {"host": "db.internal", "port": 5432},
"payments": lambda: {"currency": "INR", "retries": 3},
}
class Settings:
"""Loads each section the first time it is read, then keeps it."""
def __init__(self, loaders):
self._loaders = loaders
self.loads = 0
def __getattr__(self, name): # called only when the normal lookup fails
try:
loader = self._loaders[name]
except KeyError:
raise AttributeError(f"no settings section {name!r}", name=name, obj=self) from None
self.loads += 1
value = loader()
setattr(self, name, value) # cached: the next read finds it in the instance
return value
s = Settings(LOADERS)
print(s.payments["currency"], s.payments["retries"], s.database["port"])
print("loads:", s.loads, "| own attributes:", sorted(vars(s)))
print(hasattr(s, "smtp"), getattr(s, "smtp", "not configured"))
class LeakySettings(Settings):
"""The same, except that an unknown name lets KeyError escape."""
def __getattr__(self, name):
value = self._loaders[name]()
setattr(self, name, value)
return value
leaky = LeakySettings(LOADERS)
try:
hasattr(leaky, "smtp")
except KeyError as e:
print("hasattr() raised KeyError:", e)
try:
getattr(leaky, "smtp", "not configured")
except KeyError as e:
print("getattr() with a default raised KeyError:", e) Output
INR 3 5432 loads: 2 | own attributes: ['_loaders', 'database', 'loads', 'payments'] False not configured hasattr() raised KeyError: 'smtp' getattr() with a default raised KeyError: 'smtp'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lazy_settings.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
- Three reads cost two loads: the second read of
s.paymentsfound the stored value in the instance’s dictionary and never reached__getattr__. - For a name it cannot provide,
__getattr__must raiseAttributeError, becausehasattr()andgetattr()with a default treat exactly that exception as “no such attribute”.Settingstherefore answersFalseand “not configured”. Passingname=andobj=toAttributeError, which Python accepts since version 3.10, gives tracebacks what they need to suggest a similar name. LeakySettingslets the dictionary’sKeyErrorescape. Nowhasattr()andgetattr()with a default raise instead of answering, and any code that asks “is smtp configured?” crashes.
A failed lookup also includes a property whose getter raises AttributeError: Python then calls __getattr__ with the
property’s name. Keep that in mind when a class has both and an error names an attribute that plainly exists.
Checkpoint
Check: __getattr__ as a fallback
An override of every read sees its own reads too
__getattribute__ replaces the lookup itself. It runs for every attribute read on the instance, found or not, methods
included, which makes it the tool for counting or controlling every access, and the easiest special method to get
wrong. The first class below counts reads the obvious way; the second counts them correctly:
class Broken:
"""Counts reads, but reads self.reads to do it."""
def __init__(self):
self.reads = 0
def __getattribute__(self, name):
self.reads += 1 # this read calls __getattribute__ again
return object.__getattribute__(self, name)
try:
Broken().reads
except RecursionError as e:
print("Broken:", type(e).__name__)
class Counted:
"""Counts every attribute read that goes through __getattribute__."""
def __init__(self, price, qty):
self.price = price
self.qty = qty
self.reads = {}
def __getattribute__(self, name):
reads = object.__getattribute__(self, "reads") # the default lookup: no recursion
reads[name] = reads.get(name, 0) + 1
return object.__getattribute__(self, name)
def total(self):
return self.price * self.qty
def __repr__(self):
return f"Counted({self.price}, {self.qty})"
c = Counted(250, 4)
print(c.total(), c.total())
print(c) # repr() finds __repr__ on the class itself
print(object.__getattribute__(c, "reads")) Output
Broken: RecursionError
1000 1000
Counted(250, 4)
{'total': 2, 'price': 3, 'qty': 3}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 count_reads.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Follow the calls behind both results. The arrow that loops back is the override calling itself, and the third column is the default lookup, which a correct override uses for every real read:
The drawing shows the whole scene; its 11 steps are listed below.
Every step as text
- Broken().reads. Reading reads on a Broken instance calls type(obj).__getattribute__, the override, with the name 'reads'.
- A read inside the read. The override's first line, self.reads += 1, reads self.reads. That is an attribute read of the same instance, so Python calls the override again.
- And again. The new call starts with the same line, and so does the next one. Every read needs another read first, so no call ever gets as far as object.__getattribute__.
- RecursionError. Python stops the chain at its recursion limit with RecursionError, which the program catches and prints. Broken never counted a single read.
- c.total on Counted. Counted's override is called for c.total, as it is for every read of the instance, found or not.
- The counter, safely. It reaches its counter with object.__getattribute__(self, 'reads'). That is the default lookup itself, which never calls the override, so there is no second call.
- The dict comes back. The default lookup finds reads in vars(c) and returns the dictionary, and the override adds 1 to reads['total'].
- The real lookup. Then it asks the default lookup for total itself, which finds the function on the class.
- A bound method. The default lookup binds the function to c and hands the bound method back to the override.
- Back to your code. The override returns it, and your code calls it. Inside total(), self.price and self.qty are reads of c as well, and each one goes through the override and is counted the same way.
- print(c). print(c) needs __repr__. Python looks it up on the class Counted, as it does for every implicit use of a special method, so the override is not called and nothing is counted for it.
Broken.__getattribute__readsself.reads, and that read is itself an attribute read of the instance, so it calls__getattribute__again, which readsself.readsagain, until Python gives up withRecursionError.Countedreaches attributes throughobject.__getattribute__(self, name), the default lookup, which never calls the override. Every read inside its methods counts: two calls oftotal()readtotaltwice andpriceandqtytwice each, and the repr read each of them once more.__repr__itself is missing from the counts.print(c)finds it on the class, as every implicit use of a special method does, so the override never sees that lookup. The last line readsreadsthroughobject.__getattribute__as well, so printing the counts does not change them.
When a class defines both hooks, __getattribute__ runs first and __getattr__ only after it raises AttributeError.
Reach for __getattribute__ rarely: it adds a Python function call to every attribute read on its instances,
self.__dict__ and the class’s own helpers included, while __getattr__ covers defaults, lazy values and delegation
without touching the names that exist. Side by side, the two hooks differ in when they run, what they cost and how
they go wrong:
| Compared on | __getattr__ |
__getattribute__ |
|---|---|---|
| When it runs | Only for names the normal lookup cannot find | For every read on the instance, found or not |
| Names that exist | Never sees them, so their reads cost nothing extra | Sees each one, with a Python call per read |
| Typical use | Defaults, lazy values, forwarding to another object | Counting or controlling every read |
| The usual bug | Raising something other than AttributeError |
Reading through self inside it, until RecursionError |
Key idea
Inside __getattribute__, every read through self is another call of __getattribute__, so reach the real
attributes through object.__getattribute__(self, name).
Checkpoint
Check: __getattribute__ without recursion
A proxy forwards names, but len() looks on the class
A proxy stands in for another object and forwards what it does not handle itself. Wrappers that add logging, access
checks or remote calls are proxies, and __getattr__ makes the forwarding a few lines long:
class LoggingProxy:
"""Wraps any object, forwards attribute access to it and logs each name used."""
def __init__(self, target):
# object.__setattr__ stores on the proxy itself, past our own __setattr__.
object.__setattr__(self, "_target", target)
object.__setattr__(self, "log", [])
def __getattr__(self, name): # names the proxy itself does not have
self.log.append(f"get {name}")
return getattr(self._target, name)
def __setattr__(self, name, value): # every assignment goes to the target
self.log.append(f"set {name}")
setattr(self._target, name, value)
def __dir__(self): # what dir() and tab completion show
return dir(self._target)
queue = LoggingProxy([30, 10, 20])
queue.append(5)
queue.sort()
print(queue._target, queue.count(10))
print(queue.log)
try:
len(queue)
except TypeError as e:
print("len(queue): TypeError:", e)
print("queue.__len__():", queue.__len__())
print("'sort' in dir(queue):", "sort" in dir(queue))
class ListProxy(LoggingProxy):
"""Special methods must be defined on the class, because Python looks them up there."""
def __len__(self):
return len(self._target)
def __iter__(self):
return iter(self._target)
def __getitem__(self, index):
return self._target[index]
q = ListProxy([3, 1, 2])
print(len(q), list(q), q[-1], 2 in q, q.log) Output
[5, 10, 20, 30] 1 ['get append', 'get sort', 'get count'] len(queue): TypeError: object of type 'LoggingProxy' has no len() queue.__len__(): 4 'sort' in dir(queue): True 3 [3, 1, 2] 2 True []
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 logging_proxy.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The run took two different routes to the list. Step through both: an attribute read that reaches the list through
__getattr__, and len(), which goes straight to the class:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- An attribute read. queue.append is an ordinary attribute read. The default lookup searches the proxy's own dictionary, which holds only _target and log, and its class, which has no append.
- The fallback runs. The normal lookup failed, so Python calls __getattr__(queue, 'append'). It writes 'get append' to the log and reads the name from the target.
- Forwarded to the list. getattr(self._target, 'append') returns the list's own bound method, and the call appends 5 to the list.
- len(queue). len() does not read an attribute of queue. It looks __len__ up on type(queue), the class LoggingProxy, and never asks the instance or __getattr__.
- Nothing on the class. LoggingProxy defines no __len__, so len() raises TypeError: object of type 'LoggingProxy' has no len(). The log did not change, because __getattr__ was never called.
- On the class, it works. ListProxy adds __len__, __iter__ and __getitem__ to the class itself. len(q) now finds __len__ where it looks, and ListProxy([3, 1, 2]) has a length of 3.
- The proxy keeps
_targetandlogin its own dictionary, stored withobject.__setattr__because its own__setattr__sends assignments to the target. Reads of those two names succeed in the normal lookup and are never forwarded. append,sortandcountare not attributes of the proxy, so each read reached__getattr__, was logged and came back as the list’s own bound method.len(queue)failed although the list has a length. Implicit uses of special methods, bylen(),iter(),in, indexing, operators,repr()andwith, look the method up on the type, skipping the instance,__getattribute__and__getattr__. Written out as an attribute,queue.__len__()is an ordinary read, so it was forwarded and worked.ListProxydefines the special methods it needs on the class, andlen(),list(), indexing andinwork.infell back to__iter__, because the class has no__contains__, and the log stayed empty: none of these went through__getattr__.__dir__makesdir(queue)list the target’s names, so tab completion in a shell or an editor shows what the proxy can really do.
The rule is deliberate. Methods such as __hash__ and __repr__ exist on every object, classes included, so looking
them up through the instance would find the wrong one whenever the object is itself a class; the language reference’s
section on special method lookup shows the effect. For a proxy it means writing out, once, the special methods it
promises to support.
Checkpoint
Check: Proxies and special methods
A module can keep an old name working, with a warning
A module can define __getattr__ and __dir__ as plain functions. Python calls the module’s __getattr__(name) when a
name is not found in it, which is the usual way to keep an old name working while warning about it:
Python (main.py) · old_names/main.py
import warnings
import geometry
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
print(round(geometry.area_of_circle(2), 2)) # the old name still works ...
print([str(w.message) for w in caught]) # ... with a warning
print(round(geometry.circle_area(2), 2), dir(geometry))
try:
geometry.volume
except AttributeError as e:
print("AttributeError:", e) Output
12.57 ['area_of_circle() is deprecated: use circle_area()'] 12.57 ['circle_area'] AttributeError: module 'geometry' has no attribute 'volume'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 main.py
Python (geometry.py) · old_names/geometry.py
"""Shapes. Version 2 renamed area_of_circle() to circle_area()."""
import math
import warnings
_RENAMED = {"area_of_circle": "circle_area"}
def circle_area(radius):
return math.pi * radius**2
def __getattr__(name): # called only for names this module does not define
if name in _RENAMED:
new = _RENAMED[name]
warnings.warn(f"{name}() is deprecated: use {new}()", DeprecationWarning, stacklevel=2)
return globals()[new]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def __dir__(): # what dir(geometry) lists
return ["circle_area"] Output
It printed nothing.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 geometry.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
geometry.py defines only circle_area(). Reading geometry.area_of_circle fails the normal lookup, so the module’s
__getattr__ issues a DeprecationWarning and returns the new function, and stacklevel=2 makes the warning point at
the caller’s line instead of the module’s. An unknown name still raises AttributeError, and dir(geometry) lists only
the current name. The hook also serves from geometry import area_of_circle, but not code inside geometry.py itself,
which reads its own names directly.
Version note
Python 3.15 makes the suggestions in AttributeError messages smarter. When the missing name exists on one of the
object’s own attributes, the message suggests the longer path, such as '.inner.area', and on built-in types it
recognises method names from other languages, suggesting '.append' for list.push. The examples on this page run on
Python 3.14.8.
Checkpoint
Check: A module's __getattr__
Practise: a read-only proxy, then check yourself
The exercise asks for a proxy that forwards every read and refuses every change, with the special methods it needs written on its class. Its sample tests run in your browser.
Exercise · Medium · Python
A read-only proxy that forwards reads and refuses changes
ReadOnlyProxy(target) in readonly.py wraps any object so that code can read from it but cannot change its attributes. Complete the class:
- Reading an attribute through the proxy returns the target's attribute, methods included:
ReadOnlyProxy("asha").upper()is"ASHA". A name the target does not have raisesAttributeError, sohasattr()andgetattr()with a default keep working. - Assigning, adding or deleting any attribute through the proxy raises
AttributeError, and the target stays as it was. dir(proxy)lists the target's attributes.len(), iteration,inand indexing work on the proxy whenever the target supports them, andrepr(proxy)isReadOnlyProxy(followed by the target's repr and).
The proxy controls attribute access, not what methods do: ReadOnlyProxy([1]).append(2) still changes the list, and the tests do not ask you to stop that.
Starter code · readonly.py
class ReadOnlyProxy:
"""Wraps target: reads are forwarded to it; assignments and deletions raise AttributeError."""
def __init__(self, target):
self._target = target The sample tests · test_readonly.py
from readonly import ReadOnlyProxy
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def moved(self, dx):
return Point(self.x + dx, self.y)
def refuses(action):
try:
action()
except AttributeError:
return True
return False
def test_reads_are_forwarded():
"""attributes and methods of the target are read through the proxy"""
p = ReadOnlyProxy(Point(2, 3))
assert (p.x, p.y) == (2, 3)
assert p.moved(5).x == 7
assert ReadOnlyProxy("asha").upper() == "ASHA"
def test_writes_and_deletes_are_refused():
"""setting, adding or deleting an attribute raises AttributeError and changes nothing"""
target = Point(2, 3)
p = ReadOnlyProxy(target)
assert refuses(lambda: setattr(p, "x", 10)), "p.x = 10 should raise AttributeError"
assert refuses(lambda: setattr(p, "z", 1)), "p.z = 1 should raise AttributeError"
assert refuses(lambda: delattr(p, "y")), "del p.y should raise AttributeError"
assert refuses(lambda: delattr(p, "_target")), "the proxy's own attributes cannot be deleted either"
assert (target.x, target.y) == (2, 3)
assert not hasattr(target, "z")
assert p.x == 2, "the proxy still works after the refused changes"
def test_missing_names():
"""a name the target lacks raises AttributeError, so hasattr() and getattr() with a default work"""
p = ReadOnlyProxy(Point(2, 3))
assert not hasattr(p, "colour")
assert getattr(p, "colour", "none") == "none"
def test_dir_lists_the_target():
"""dir() of the proxy lists the target's attributes"""
names = dir(ReadOnlyProxy(Point(2, 3)))
assert "x" in names and "moved" in names
def test_special_methods():
"""len(), iteration, in, indexing and repr() work through the proxy"""
p = ReadOnlyProxy([10, 20, 30])
assert len(p) == 3
assert list(p) == [10, 20, 30]
assert 20 in p and 40 not in p
assert p[-1] == 30 and p[0:2] == [10, 20]
assert repr(p) == "ReadOnlyProxy([10, 20, 30])" A hint
Store the target with object.__setattr__(self, "_target", target): a plain self._target = target would run your own __setattr__, which refuses every assignment. Forward reads in __getattr__, and remember that len(), iter(), in, [] and repr() look for their special methods on the class, so each one needs its own method on ReadOnlyProxy.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then test all six ideas of the lesson. Practice starts where your answers have been weakest and explains every answer the moment you give it:
Practice that adapts to you
Practise this lesson
12 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher to 2–5 years · What is the difference between __getattr__ and __getattribute__?
__getattribute__ runs for every attribute read on an instance, whether the attribute exists or not; the default one,
object.__getattribute__, performs the normal search through data descriptors, the instance’s dictionary and the class.
__getattr__ is a fallback: Python calls it only after that search has raised AttributeError, so it never sees names
that exist. Use __getattr__ for defaults, lazy loading and forwarding to another object, where only the missing names
matter. Override __getattribute__ only to observe or control every read, and reach the real attributes through
object.__getattribute__(self, name) inside it, or each read calls the method again until RecursionError. Neither
hook sees the implicit special-method lookups of len(), + or repr(), which go straight to the class.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A wrapper class forwards attribute reads to self._target from __getattr__. It works everywhere until copy.copy() or pickle.loads() meets it, and both die with RecursionError. Explain the chain of calls, and fix the class so that copies and pickles work.
-
Mid to senior
A class has a property area and a __getattr__ that raises "no such field". Production logs show AttributeError: no such field 'area', although area is plainly defined. What is going on, how do you find the real bug, and how do you stop such errors from being hidden?
-
Senior to staff
Your team wrapped a vendor's payment client in a proxy: __getattr__ forwards every attribute and adds retries to methods named in a list. Months later, with client: fails, isinstance checks against the vendor's class fail, and a new vendor method runs without retries. What went wrong in the design, and what would you build instead?
Key takeaways
obj.namechecks for a data descriptor on the class, thenvars(obj), then the rest of the class, and calls__getattr__only when all three fail.- A property beats an instance attribute of the same name; a method or a class attribute does not.
- Assignment and
delchange the instance’s dictionary unless a data descriptor on the class takes over; through an instance they never change the class. __getattr__must raiseAttributeErrorfor unknown names, orhasattr()andgetattr()with a default break.- Inside
__getattribute__and__setattr__, reach the real attributes throughobject.__getattribute__andobject.__setattr__. len(),in, indexing and other syntax look special methods up on the class, so a proxy defines the ones it supports.
Terms in this lesson
- attribute lookup
- What Python does to evaluate obj.name: the default __getattribute__ looks for a data descriptor on the class, then in the instance's own dictionary, then for anything else on the class, and only when all three fail is __getattr__ called.
- proxy
- An object that stands in for another and forwards what it does not handle itself, often from __getattr__. Special methods it supports, such as __len__, must be defined on its class, because len() and other syntax look them up there.
References
- The Python Language Reference: customizing attribute access (Python Software Foundation)
- The Python Language Reference: invoking descriptors (Python Software Foundation)
- The Python Language Reference: special method lookup (Python Software Foundation)
- Descriptor Guide: invocation from an instance (Python Software Foundation)
- Built-in Functions: dir() (Python Software Foundation)
- Built-in Exceptions: AttributeError (Python Software Foundation)
- PEP 562: module __getattr__ and __dir__ (Python Software Foundation)
- What's New In Python 3.15: improved error messages (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress