Code Trace: Class Attribute vs Instance Attribute Shadowing
Interview Question: "What does each print statement output, and why? Explain how Python resolves attribute lookups through
__dict__, why assigning toselfcreates variable shadowing, and what happens when using the in-place+=operator on class attributes."class Counter:count = 0def __init__(self):Counter.count += 1def reset(self):self.count = 0c1 = Counter()c2 = Counter()print(Counter.count)c1.reset()print(c1.count)print(Counter.count)print(c2.count)
Python attributes don't work like static members in C++ or Java. Every object keeps its own small dictionary of attributes (__dict__), and reading or writing obj.attr is really just a dictionary lookup — reading checks the instance's dictionary first and falls back to the class's, but writing always goes straight into the instance's own dictionary. That asymmetry is the whole trick behind this trace.
Exact Output
2
0
2
2
Step-by-Step Namespace Trace (__dict__)
After c1 = Counter() and c2 = Counter() run, Counter.__init__ has bumped the shared class attribute twice, but neither instance has written anything of its own yet:
{'count': 2}{}{}print(Counter.count)reads straight fromCounter.__dict__→ prints2.c1.reset()runsself.count = 0. This is a write, and writes always land in the instance's own dictionary — never the class's:
{'count': 2}{'count': 0}{}print(c1.count)finds'count'directly inc1.__dict__→ prints0.print(Counter.count)still readsCounter.__dict__directly → prints2.print(c2.count)—c2.__dict__is empty, so the lookup falls back toCounter.__dict__→ prints2.
Python's Attribute Lookup Protocol
When code evaluates an expression like obj.attribute, the Python interpreter searches through namespaces in a strict order:
- Data Descriptors: Checked on the class of
objand its MRO (any class attribute defining both__get__and__set__, such as a@property). - Instance Dictionary (
obj.__dict__): If the attribute is found in the instance's own dictionary, its value is immediately returned. - Non-Data Descriptors & Class Attributes: If not in
obj.__dict__, Python checkstype(obj).__dict__(methods, class attributes,@classmethod). - Base Classes (MRO): Traverses the superclasses in C3 Linearization order.
- Fallback (
__getattr__): If all lookups fail, Python callsobj.__getattr__("attribute")if defined, or raises anAttributeError.
The Asymmetry Between Reading and Writing:
- Reading (
print(obj.count)): Searchesc1.__dict__first; if missing, falls back toCounter.__dict__. - Writing (
self.count = 0): Always writes directly toself.__dict__. It never writes toCounter.__dict__. This creates a local instance attribute that permanently "shadows" (masks) the class attribute for that specific instance.
The Dangerous += Shadowing Trap
A notorious bug in production Python services involves modifying class-level counters using self:
class Worker:
tasks_processed = 0
def process(self):
# SUBTLE TRAP: What does this do?
self.tasks_processed += 1
What happens under the hood?
The in-place operator self.tasks_processed += 1 expands to:
- Right-Hand Side (
self.tasks_processed + 1): Python readsself.tasks_processed. Sinceself.__dict__is empty, it readsWorker.tasks_processed(). . - Left-Hand Side (
self.tasks_processed = 1): Python executes an assignment toself! - The Result: It creates a brand-new instance attribute
tasks_processed = 1insideself.__dict__. Worker.tasks_processedremains stuck at0forever! Every worker object now maintains an isolated instance counter instead of updating the shared telemetry metric.
The Proper Fix:
To mutate a class attribute, either reference the class directly or use type(self):
Counter.count = 0 # Explicit class reference
type(self).count = 0 # Polymorphic class reference
self.__class__.count = 0 # Alternative
Mutable Class Attributes: a Different Trap
While assigning an immutable integer (self.count = 0) creates a shadowing variable, modifying a mutable object (like a list or dict) exhibits the complete opposite behavior:
class Service:
shared_cache = [] # Mutable Class Attribute
def add_data(self, item):
# Does this shadow?
self.shared_cache.append(item)
- When calling
self.shared_cache.append(item), there is no assignment operator (=). - Python reads
self.shared_cache, finds the shared list inService.__dict__, and mutates that exact memory buffer in place. self.__dict__remains completely empty.- Every single instance of
Servicenow sees the appended data! This is why mutable default values in classes and functions are considered a major anti-pattern.
Python: Runnable Verification
"""
Standalone Python verification for Class vs Instance Attribute Shadowing.
Run with: python attribute_shadowing_trace.py
"""
class Counter:
count = 0
def __init__(self):
Counter.count += 1
def reset(self):
self.count = 0
if __name__ == "__main__":
c1 = Counter()
c2 = Counter()
# Step 1: Initial state
print("Initial Class Count:", Counter.count)
print("c1.__dict__:", c1.__dict__)
print("c2.__dict__:", c2.__dict__)
assert Counter.count == 2
assert c1.__dict__ == {}
# Step 2: c1 resets (Shadowing occurs)
c1.reset()
print("\nAfter c1.reset():")
print("c1.count: ", c1.count)
print("Counter.count: ", Counter.count)
print("c2.count: ", c2.count)
print("c1.__dict__: ", c1.__dict__)
print("c2.__dict__: ", c2.__dict__)
assert c1.count == 0
assert Counter.count == 2
assert c2.count == 2
assert c1.__dict__ == {"count": 0}
assert c2.__dict__ == {}
# Step 3: Demonstrating the += trap
class TrapDemo:
val = 10
def bad_increment(self):
self.val += 1
t = TrapDemo()
t.bad_increment()
print("\n+= Trap Demonstration:")
print("TrapDemo.val (Class):", TrapDemo.val)
print("t.val (Instance): ", t.val)
assert TrapDemo.val == 10 # Class attribute was NOT incremented!
assert t.val == 11 # Instance attribute shadowed it!
print("Verification passed successfully.")
Say this in the interview
"The output is 2, 0, 2, 2. Class attributes live in Counter.__dict__; instance attributes live in self.__dict__. Reading falls back from the instance's dictionary to the class's if the instance doesn't have it, but writing always goes straight into the instance's own dictionary — it never touches the class. So c1.reset()'s self.count = 0 creates a local count that shadows the class attribute for c1 only; Counter.count and c2.count are untouched. The same asymmetry causes a real production bug: self.count += 1 reads the class attribute, adds one, then writes that result as a new instance attribute — so the shared counter never actually increments. Mutating a class attribute safely requires naming the class explicitly, like Counter.count = 0 or type(self).count = 0."