In Part 1 you built classes: __init__, self, attributes, methods. In Part 2 you stacked them into hierarchies and watched the MRO decide which method actually runs.
Both of those lessons quietly assumed something: that when you write self.balance = 1000, a thing called balance gets stored somewhere on the object, and when you later write acct.balance, that same thing comes back. That’s true enough to learn on. It is not what happens.
This lesson is about what actually happens — and the payoff is not trivia. It’s three of the most practical skills in Python:
- Encapsulation without ceremony. Python has no
privatekeyword. Coming from Java or C# this looks like a gap, so people either write pointlessget_x()/set_x()methods out of habit, or they try to fake privacy with__xand get confused when it doesn’t work. Neither is right, and the reason why is genuinely interesting. - Knowing where your data lives. Class variables and instance variables look identical at the call site (
obj.xboth times) and behave completely differently. The gap between them produces one of the most expensive bugs a Python beginner ships: a class attribute that silently accumulates every object’s data into one shared pile. - Picking the right kind of method. Instance, class, static. Most people learn
@staticmethodfirst and use it most, which is exactly backwards.
Everything below was run on Python 3.12. Type it. This is a lesson about a mechanism you can literally print out and look at, so looking at it is the point.
Why this matters
Here’s the scenario, and it’s not hypothetical — it’s most of a career.
Monday, you ship a class. It has a balance attribute. It’s a number; people read it and write it; the code is four lines long and perfect:
class Account:
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
Thursday, a bug report: an account went negative. New rule — balance can never be negative.
Now, what did your language cost you?
In Java, you knew this day was coming. That’s why the Java house style says: never expose a field. Make it private, write getBalance(), write setBalance(), and let the IDE generate the boilerplate. You did it on Monday for a rule you didn’t have yet, on the off-chance you’d need it Thursday. Multiply by every field, in every class, forever. If you didn’t do it — if you shipped a public field — then Thursday means changing the field to private and rewriting every acct.balance in every caller, in every downstream project, to acct.getBalance(). That’s a breaking API change for a validation rule.
In Python, Thursday costs you six lines inside the class and nothing anywhere else. Callers keep writing acct.balance = 500. They don’t know, don’t care, and don’t recompile. The getter/setter boilerplate is unnecessary on Monday precisely because it’s retrofittable on Thursday.
That mechanism is @property, and it is the single reason the “Python has no private” design isn’t a mistake. Java needs getters up front because a field cannot later become a method call. In Python it can. So the pre-emptive boilerplate buys you nothing, and Python skips it.
| The problem | The Java/C# answer | The Python answer |
|---|---|---|
| A field might need validation later | Make everything private + generate getters/setters on day one |
Ship the plain attribute. Add @property the day you need it |
| Callers must not break when it changes | The getter was always there, so nothing changes | The attribute name still works — it’s now a method call in disguise |
| Some state is internal | private — the compiler enforces it |
_leading_underscore — a convention the reader honours |
| Subclass might clash with my field name | private fields don’t collide |
__double_underscore → name mangling (_Class__x) |
| Stop callers doing something dumb | The compiler stops them | Nothing stops them. This is deliberate |
That last row is the philosophical bit, and it has a name: “we’re all consenting adults here.” Python’s position is that access control is a communication problem, not a security problem. You mark what’s internal, and other developers respect it — not because they can’t reach it, but because you told them not to and they don’t want to maintain code that breaks on your next release. Nobody sane pokes at _internal_cache and then files a bug when it disappears.
Is that naïve? Consider what the alternative actually buys. private stops a colleague, not an attacker — anyone with Java reflection or a debugger goes straight through it, and if they’re running in your process it was never a boundary anyway. What private really buys is a compiler-checked note that says “don’t.” Python writes the same note in ASCII and spends the saved complexity elsewhere. The trade-off is real, and it goes both ways: Python’s version relies on people reading. But the payoff — no boilerplate, and the freedom to turn any attribute into computed behaviour later — is worth more than most people expect until they’ve lived in both.
The mental model for this entire lesson, and it’s worth memorising before anything else: an attribute access is not a lookup in one place — it’s a search. obj.x searches the class first, then the object, then the object’s ancestors. obj.x = 1 doesn’t search at all; it writes to the object. Almost everything strange in this lesson falls out of those two sentences.
Encapsulation the Python way: _single, __double, and no private
Python has exactly three naming conventions for visibility, and only one of them does anything to the runtime — and even then, not what people assume.
class Vault:
def __init__(self):
self.public = "anyone" # public API: touch freely
self._internal = "please don't" # convention: internal, may change
self.__mangled = "renamed" # name-mangled to _Vault__mangled
def peek(self):
return self.__mangled # inside the class body, this resolves
v = Vault()
print(v.__dict__)
{'public': 'anyone', '_internal': "please don't", '_Vault__mangled': 'renamed'}
Stop and read that output, because it settles the whole argument. There’s no “private” flag, no access level, no hidden storage. It’s a dict. Every attribute you set on an object goes into an ordinary dictionary hanging off that object, and you can print it. public and _internal are stored under exactly the names you wrote. __mangled is stored under a different name: _Vault__mangled.
That’s it. That’s the entire enforcement mechanism.
| You write | Stored as | Runtime enforcement | Means |
|---|---|---|---|
self.x |
x |
None | Public. Part of your API. Callers may rely on it |
self._x |
_x |
None whatsoever | Internal. “May change without notice. Don’t.” |
self.__x |
_ClassName__x |
Renamed — that’s all | “Don’t let a subclass collide with this” |
self.__x__ |
__x__ |
None — not mangled | Reserved for Python (__init__, __len__). Never invent your own |
_single: a note, not a lock
class Cfg:
def __init__(self):
self._token = "s3cret"
cfg = Cfg()
print(cfg._token)
# => s3cret
No error. No warning. It just works, because _token is a completely ordinary attribute with a slightly unusual first character. The underscore is a message to a human reader: this is plumbing; I reserve the right to rename or delete it in the next patch release; if you depend on it, that’s on you.
Linters respect it (your IDE will grey it out or warn on access from outside), and it has exactly one real runtime effect anywhere in the language — from module import * skips module-level names starting with _:
# mod.py
public_thing = "exported"
_private_thing = "not exported by *"
from mod import *
print(public_thing) # => exported
print(_private_thing) # NameError: name '_private_thing' is not defined
import mod
print(mod._private_thing) # => not exported by * (explicit import still works)
Even there it’s a default, not a lock — name it explicitly and it comes right through. _ is a curtain, not a wall.
__double: name mangling, and what it’s really for
Two leading underscores (and at most one trailing) trigger name mangling: inside a class body, the compiler rewrites self.__x to self._ClassName__x. Everywhere. Automatically.
People assume this is Python’s grudging attempt at private. It isn’t, and believing that leads you to use it wrongly. Here’s what it’s actually for:
class Base:
def __init__(self):
self.__id = "base-id" # becomes self._Base__id
def base_id(self):
return self.__id
class Child(Base):
def __init__(self):
super().__init__()
self.__id = "child-id" # becomes self._Child__id — a DIFFERENT attribute
def child_id(self):
return self.__id
c = Child()
print(c.base_id(), "|", c.child_id())
print(c.__dict__)
base-id | child-id
{'_Base__id': 'base-id', '_Child__id': 'child-id'}
That is the feature. Base and Child each wanted an attribute called __id, and both got one, and neither clobbered the other. Without mangling, Child.__init__ would have overwritten Base’s value and base_id() would return "child-id" — a bug that only appears when someone subclasses your class, which is to say, in someone else’s codebase, months later.
So name mangling is for avoiding accidental collisions in subclasses, not for hiding things. The distinction is easy to prove:
v = Vault()
print(v._Vault__mangled) # => renamed ← nothing is hidden
print(v.__mangled) # AttributeError
Traceback (most recent call last):
File "demo.py", line 6, in <module>
print(v.__mangled)
^^^^^^^^^^^
AttributeError: 'Vault' object has no attribute '__mangled'
Look at why that error happens. It’s not an access violation — Python isn’t refusing you. Mangling only happens inside a class body, so at module level v.__mangled is a literal request for an attribute named __mangled, which genuinely doesn’t exist. The attribute you wanted is sitting right there under _Vault__mangled, fully readable by anyone who spends four seconds looking at __dict__. It’s not a lock; it’s a rename, and the rename is documented in the language reference.
| Question | Answer |
|---|---|
Does __x make an attribute private? |
No. It renames it to _Class__x. Anyone can read it |
| Is it a security feature? | No. It stops collisions, not people. Never use it for secrets |
| When should I use it? | Rarely: a base class in a library that will be widely subclassed |
When should I use _x instead? |
Almost always. It says “internal” without the surprises |
Does it affect __init__, __len__? |
No — dunders (leading and trailing) are exempt |
| Does it work outside a class body? | No. That’s exactly why v.__mangled raises AttributeError |
Practical guidance, stated plainly: use _single for internal state — it’s the convention the whole ecosystem reads. Reach for __double only when you’re writing a base class that others will subclass and you genuinely need an attribute nobody can accidentally shadow. If you’re using __double because you want privacy, you’ve misunderstood the tool, and you’ll be the one swearing at it in six months when a subclass or a test can’t reach the thing you “protected.”
And never, ever put a secret in an attribute and call it protected. _api_key, __api_key, and api_key are all equally readable in a stack trace, a repr, a debugger, and a crash dump. Encapsulation is about change management, not confidentiality.
| What “protects” the attribute | What it actually stops |
|---|---|
_x convention |
Nothing at runtime. Stops a careful colleague and import * |
__x mangling |
Nothing. Stops an accidental subclass collision |
@property with no setter |
Actually raises AttributeError on assignment |
@property setter with validation |
Actually raises ValueError/TypeError on bad data |
__slots__ |
Actually raises AttributeError on an unknown attribute |
| A code review | More than all of the above combined |
Notice the pattern in that table. The things that genuinely enforce anything are the ones in the next section — and they enforce correctness, not access. That’s Python’s whole position on encapsulation in one sentence: don’t guard who touches your data; guard that your data stays valid.
@property: why Python doesn’t need getters and setters
This is the centrepiece of the lesson. If you take one thing away, take this.
Monday: ship the plain attribute
class AccountV1:
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance # a plain, public attribute
And somewhere, a caller — possibly in a different file, a different package, a different company:
def caller(acct):
acct.balance = acct.balance + 500
return f"{acct.owner}: {acct.balance}"
a = AccountV1("Asha", 1000)
print(caller(a))
a.balance = -9999 # nothing stops this. yet.
print("V1 allows:", a.balance)
Asha: 1500
V1 allows: -9999
There’s the bug: -9999. And there’s the Java argument in miniature — “see, you should have written a setter.”
Thursday: add validation, change no callers
class AccountV2:
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance # NOTE: goes THROUGH the setter below
@property
def balance(self): # the GETTER — runs on `acct.balance`
return self._balance
@balance.setter
def balance(self, value): # the SETTER — runs on `acct.balance = x`
if not isinstance(value, (int, float)):
raise TypeError(f"balance must be a number, got {type(value).__name__}")
if value < 0:
raise ValueError(f"balance cannot be negative (got {value})")
self._balance = value
Now run the identical caller function. Not one character of it changed:
b = AccountV2("Asha", 1000)
print(caller(b)) # the SAME caller from Monday
try:
b.balance = -9999
except ValueError as e:
print("V2 blocks:", type(e).__name__ + ":", e)
print("b.__dict__:", b.__dict__)
Asha: 1500
V2 blocks: ValueError: balance cannot be negative (got -9999)
b.__dict__: {'owner': 'Asha', '_balance': 1500}
Read that three-line output slowly, because it’s the whole argument:
caller(b)still works.acct.balanceandacct.balance = xare unchanged at every call site in the world. The public API is byte-for-byte identical.b.balance = -9999now raises. The rule is enforced — including inside__init__, for free, becauseself.balance = balancegoes through the same setter.b.__dict__proves the mechanism: there is nobalancekey. The real storage is_balance; the namebalancenow belongs to apropertyobject living on the class.
That third point is the trick, and it’s worth being explicit about. balance was an entry in the instance dict. It’s now an entry in the class dict — and the class is searched first. That’s how a plain attribute gets intercepted without the caller noticing:
print('balance' in AccountV2.__dict__) # => True
print(type(AccountV2.__dict__['balance'])) # => <class 'property'>
So: you never need getters and setters up front, because you can always add them later. The Java pattern exists to solve a problem Python doesn’t have. Writing get_balance()/set_balance() in Python isn’t “defensive,” it’s just noise that makes every call site uglier for a benefit you already have.
The corresponding rule, which is the one people actually get wrong: don’t write a @property up front either. A property that only does return self._x is the same boilerplate wearing a fashionable hat — you’ve added indirection, a private-ish attribute, and a stack frame per read, and bought nothing. Start with the plain attribute. Add the property on the day you have a rule to enforce. That’s not laziness; it’s the entire design paying off.
The three decorators
class Rect:
def __init__(self, w, h):
self.width = w
self.height = h
@property
def label(self): # getter — obj.label
return self._label
@label.setter
def label(self, v): # setter — obj.label = x
self._label = v.strip().title()
@label.deleter
def label(self): # deleter — del obj.label
print(" (clearing label)")
del self._label
r = Rect(3, 4)
r.label = " north wing "
print(r.label) # => North Wing
del r.label
print('_label' in r.__dict__) # => False
North Wing
(clearing label)
False
Note the setter quietly normalised the input (" north wing " → "North Wing"). Properties aren’t only for rejecting bad values — coercing, normalising, logging, and lazily loading are all fair game.
| Decorator | Triggered by | Signature | Notes |
|---|---|---|---|
@property |
obj.x (read) |
def x(self) |
Defines the property. Must come first |
@x.setter |
obj.x = v (write) |
def x(self, value) |
Method must be named x — same name as the property |
@x.deleter |
del obj.x |
def x(self) |
Rare. Useful for cache invalidation |
| (omit setter) | obj.x = v |
— | Raises AttributeError → read-only property |
property(fget, fset) |
— | called, not decorated | The legacy form. You’ll meet it in old code |
The naming rule in row 2 catches everyone once, so let it catch you here rather than in production:
@balance.setter
def set_balance(self, value): # WRONG: must be named `balance`
self._balance = value
Traceback (most recent call last):
File "demo.py", line 14, in <module>
a.balance = 50
^^^^^^^^^
AttributeError: property 'balance' of 'Account' object has no setter
Why? @balance.setter returns a brand-new property object with the setter attached — and then def set_balance binds that new object to the name set_balance. The name balance still points at the old, setter-less property. Python is being perfectly consistent; you just gave the result a different name. The method under @x.setter must be named x.
Version note: Python 3.10 and older report this as a bare
AttributeError: can't set attribute— no property name, no class, no clue which of your twelve properties it was. The informative message (and the^^^^carets pinpointing the expression) arrived with the 3.11 error-message overhaul.
Computed properties: the other 50% of the value
Validation is the famous use. Derived values are the one you’ll reach for more often. A @property with no setter is a computed attribute — it looks like data, but it’s recalculated on every read, so it can never go stale:
class Rect:
def __init__(self, w, h):
self.width = w
self.height = h
@property
def area(self): # derived — always in sync, by construction
return self.width * self.height
r = Rect(3, 4)
print(r.area) # => 12
r.width = 10
print(r.area) # => 40 ← auto-updates
r.area = 99 # AttributeError
12
40
Traceback (most recent call last):
File "demo.py", line 11, in <module>
r.area = 99
^^^^^^
AttributeError: property 'area' of 'Rect' object has no setter
Compare the alternative: storing self.area = w * h in __init__. It’s correct for exactly as long as nobody touches width. The moment they do, you’re holding a stale number and no error will ever tell you. The computed property makes staleness impossible rather than unlikely — and r.area = 99 raising AttributeError is the feature, not a limitation. You’ve expressed “this is derived; it isn’t yours to set.”
| Flavour | Shape | Use for |
|---|---|---|
| Read-write property | getter + setter | Validation, coercion, logging on a real stored value |
| Read-only property | getter only | Derived values (area), IDs, anything callers must not set |
| Computed property | getter that calculates | Values derived from other attributes — can’t go stale |
| Lazy property | getter that caches into self._x |
Expensive value you want computed at most once |
functools.cached_property |
one decorator, caches automatically | The lazy pattern, done for you (3.8+) |
| Plain attribute | self.x = x |
The default. Everything with no rule attached |
cached_property is worth 30 seconds because it’s the lazy pattern with the bugs already removed:
from functools import cached_property
class Report:
def __init__(self, n):
self.n = n
@cached_property
def heavy(self):
print(" (computing once)")
return self.n ** 2
r = Report(4)
print(r.heavy) # computes
print(r.heavy) # cached — no recompute
print(r.__dict__)
(computing once)
16
16
{'n': 4, 'heavy': 16}
That __dict__ output shows exactly how it works, and it’s a lovely demonstration of the lookup rules you’re about to learn: cached_property is a non-data descriptor (it has __get__ but no __set__), so it does not win over the instance dict. It computes once, writes the result into self.__dict__['heavy'], and from then on the instance dict shadows it and the getter never runs again. Zero magic — just the shadowing rule, used on purpose. (The flip side: it’s writable and never invalidates. If n changes, heavy is a lie. Use it only for values derived from state that doesn’t move.)
So when should something be a method instead?
| Make it… | When | Example |
|---|---|---|
| A plain attribute | It’s just stored data with no rules | self.name |
| A property | Cheap, no arguments, feels like data | rect.area, acct.balance |
| A method | Takes arguments, is expensive, or does something | acct.withdraw(50), server.reboot() |
The line is about honesty, not taste. obj.x looks free and side-effect-free, so it must be roughly free and side-effect-free. A property that opens a database connection or takes 400 ms is a trap — the reader has no way to know, and they’ll cheerfully put it in a loop. If it’s expensive or it changes the world, give it parentheses. Parentheses are a warning label.
Class variables vs instance variables
Now the mechanism underneath all of it — and the source of the most expensive bug in this lesson.
A class variable is assigned in the class body. It exists once, on the class. An instance variable is assigned on self (usually in __init__). It exists once per object.
class Dog:
species = "Canis familiaris" # CLASS variable — one copy, on the class
tricks = [] # CLASS variable — and a BUG waiting
def __init__(self, name):
self.name = name # INSTANCE variable — one per object
a = Dog("Rex")
b = Dog("Bella")
print("a.__dict__ :", a.__dict__)
print("b.__dict__ :", b.__dict__)
print("Dog.__dict__ keys:", list(Dog.__dict__.keys()))
print("a.species is Dog.species:", a.species is Dog.species)
a.__dict__ : {'name': 'Rex'}
b.__dict__ : {'name': 'Bella'}
Dog.__dict__ keys: ['__module__', 'species', 'tricks', '__init__', '__dict__', '__weakref__', '__doc__']
a.species is Dog.species: True
There’s the model, printed. Each instance’s __dict__ holds only name — the thing __init__ assigned to self. species and tricks aren’t in either instance; they’re in Dog.__dict__, alongside __init__ itself. And a.species works anyway, because reading walks up to the class.
Version note: on Python 3.13+ that key list also contains
__firstlineno__and__static_attributes__— new bookkeeping, harmless. The class__dict__is a read-onlymappingproxy, not a plain dict; instance__dict__s are ordinary writable dicts.
The read path, the write path, and the diagram
Two rules govern everything:
- Reading
obj.xsearches the class (and its MRO) first for a data descriptor, thenobj.__dict__, then the class/MRO again for a plain value. First hit wins. - Writing
obj.x = vdoes not search. Barring a data descriptor, it writes straight intoobj.__dict__. It never touches the class.
That first rule surprises people who expect “instance first, then class” — and the exception it carves out for data descriptors (objects defining both __get__ and __set__, which is exactly what property is) is precisely how @property intercepts an attribute that used to be plain data. The class gets first refusal.
Here is the whole thing in one picture. Read it left to right: a caller touches s.cpu, the property gate on the class gets first refusal, a miss falls through to the instance’s own __dict__, then to the single shared class __dict__, and finally out as a value or an AttributeError. The amber path at the top is the write, and note where it stops — the instance, always.
The six badges are the six things that bite: writes never reach the class (1); the class is searched first, which is what lets a property intercept (2); validation is retrofittable, so callers never change (3); each instance owns its own dict (4); the class dict has exactly one copy of everything — the shared-mutable bug (5); and a total miss is an AttributeError (6).
| Class variable | Instance variable | |
|---|---|---|
| Assigned | In the class body: species = "..." |
On self: self.name = name |
| Lives in | Dog.__dict__ |
dog.__dict__ |
| How many copies | One, ever | One per object |
Read via obj.x |
✅ Yes — falls through to the class | ✅ Yes |
Written via obj.x = v |
❌ No — creates an instance attribute | ✅ Yes |
| Changed for everyone by | Dog.species = "..." |
Nothing — it’s per-object |
| Good for | Constants, defaults, shared counters, config | Per-object state (the normal case) |
| Dangerous when | It’s mutable ([], {}, set()) |
Basically never |
The shared mutable class attribute bug
Watch it happen:
a.tricks.append("roll over") # a is Rex
b.tricks.append("play dead") # b is Bella
print("a.tricks :", a.tricks)
print("b.tricks :", b.tricks)
print("Dog.tricks :", Dog.tricks)
print("a.tricks is b.tricks:", a.tricks is b.tricks)
print("a.__dict__ :", a.__dict__)
a.tricks : ['roll over', 'play dead']
b.tricks : ['roll over', 'play dead']
Dog.tricks : ['roll over', 'play dead']
a.tricks is b.tricks: True
a.__dict__ : {'name': 'Rex'}
Rex knows Bella’s tricks. Bella knows Rex’s. And a.tricks is b.tricks is True — because there aren’t two lists. There is one list, created once when the class statement ran, sitting in Dog.__dict__, and every dog in your program is reading and appending to it. a.__dict__ doesn’t even have a tricks key.
Follow the rules and it’s not mysterious at all:
a.tricksreads — missesa.__dict__, walks toDog.__dict__, finds the one shared list..append(...)mutates that list in place. There is no assignment here — nothing ever writes toa.__dict__, so nothing ever gets its own copy.
If you’ve read the functions lesson, you have already met this bug wearing a different suit: it’s the mutable default argument (def f(x=[])), same root cause. The class body, like a def line, runs once. Any mutable object created there is created once and shared forever. Two hiding places, one bug.
The fix is to build the mutable object per-instance, in __init__:
class Dog:
species = "Canis familiaris" # immutable → safe to share
def __init__(self, name, tricks=None):
self.name = name
self.tricks = tricks if tricks is not None else [] # fresh list per dog
self.tricks = [...] is an assignment, so it lands in dog.__dict__ — one list per object. (And note the None sentinel on the parameter, for exactly the reason above. Two mutable-default traps, dodged in one line.)
| Class attribute type | Safe? | Why |
|---|---|---|
int, str, float, bool, None |
✅ | Immutable — can only be rebound, never mutated in place |
tuple, frozenset |
✅ | Immutable |
list |
❌ | Shared. .append() on one instance is visible from all |
dict |
❌ | Shared. Same trap |
set |
❌ | Shared. Same trap |
| Any class instance | ❌ | Shared object; mutating it affects everyone |
A method / @property / @classmethod |
✅ | This is what class attributes are for |
The instinct to build: immutable class attributes are a feature; mutable class attributes are a bug. Constants, defaults, and counters belong on the class. Anything with .append() belongs in __init__. Every time.
Shadowing: writing always lands on the instance
The other half of the rule, and where the surprises are:
a.species = "Very Good Dog" # a WRITE — lands on the instance
print("a.species :", a.species)
print("b.species :", b.species)
print("Dog.species:", Dog.species)
print("a.__dict__ :", a.__dict__)
a.species : Very Good Dog
b.species : Canis familiaris
Dog.species: Canis familiaris
a.__dict__ : {'name': 'Rex', 'species': 'Very Good Dog'}
The class attribute is untouched. a now has its own species in its own dict, and because reads check the instance before the class value, a’s copy shadows it. b and Dog never noticed. This isn’t a special rule for class attributes — it’s the only rule there is: assignment writes to the instance.
And it’s reversible, which is a neat proof that nothing was overwritten:
del a.species # delete the INSTANCE attribute
print(a.species) # => Canis familiaris ← the class one was there all along
Now the bug this rule causes — the counter:
class Counter:
count = 0
def __init__(self):
self.count += 1 # BUG: reads the class attr, writes an INSTANCE attr
c1, c2, c3 = Counter(), Counter(), Counter()
print("Counter.count:", Counter.count)
print("c1.count:", c1.count, "| c1.__dict__:", c1.__dict__)
Counter.count: 0
c1.count: 1 | c1.__dict__: {'count': 1}
Three objects created; the counter says 0. And every instance thinks the count is 1.
self.count += 1 expands to self.count = self.count + 1. The right side reads — misses the instance, finds Counter.count → 0. The left side writes — and writes go to the instance. So each object computes 0 + 1 and stores 1 in its own dict. The class attribute is never touched, by any of them. Nothing errors. Nothing warns. The number is just quietly wrong forever, and c1.count returning 1 makes it look like it’s working.
The fix is to say which namespace you mean:
class CounterFixed:
count = 0
def __init__(self):
CounterFixed.count += 1 # explicit: write to the CLASS
d1, d2, d3 = CounterFixed(), CounterFixed(), CounterFixed()
print("CounterFixed.count:", CounterFixed.count, "| d1.__dict__:", d1.__dict__)
CounterFixed.count: 3 | d1.__dict__: {}
Three. And the instance dicts are empty — no per-object copies, because nothing assigned to self. Note the alternative, type(self).count += 1, is not the same thing: it writes to the actual class, so a subclass would get its own counter instead of incrementing the parent’s. Which one you want is a real design decision — just make it on purpose.
| Statement | Reads from | Writes to | Result |
|---|---|---|---|
obj.x |
instance, then class/MRO | — | First hit wins |
obj.x = v |
— | obj.__dict__, always |
Shadows the class attribute |
obj.x += 1 |
instance, then class | obj.__dict__ |
← the counter bug |
Cls.x = v |
— | Cls.__dict__ |
Changes it for every instance |
Cls.x += 1 |
Cls.__dict__ |
Cls.__dict__ |
The correct shared counter |
type(obj).x += 1 |
actual class | actual class | Subclass gets its own counter |
del obj.x |
— | removes from obj.__dict__ |
Un-shadows the class attribute |
obj.list_attr.append(v) |
instance, then class | mutates in place | ← the shared-mutable bug |
That table repays a slow read. Rows 3 and 8 are the two bugs in this lesson, and both are the same rule doing exactly what it says.
| Inspection | Shows |
|---|---|
obj.__dict__ / vars(obj) |
This object’s own attributes only |
Cls.__dict__ |
Class attributes + methods + properties (a mappingproxy) |
dir(obj) |
Every name reachable — instance + class + all ancestors |
'x' in obj.__dict__ |
Is x shadowed on this instance? ← the diagnostic |
type(obj).__mro__ |
The exact class search order for a read |
Cls.__dict__['x'] |
The raw object — see property/classmethod, undisguised |
'x' in obj.__dict__ is the debugging move worth memorising. When an attribute has a value you can’t explain, that one expression tells you which namespace you’re reading from, which is nearly always the answer.
@classmethod: cls, and the alternative constructor
An instance method receives the object as self. A class method receives the class as cls. That’s the whole difference — and the reason it matters is that a class method can create instances, which an instance method can’t sensibly do (it needs an instance to already exist).
That single fact gives @classmethod its killer app: alternative constructors.
__init__ takes exactly the arguments you designed it around. But real objects arrive from CSV rows, JSON payloads, database rows, and env vars. You could bloat __init__ with a pile of optional parameters and if branches to sniff which format it got — and people do, and it’s miserable. Or you give each source its own named front door:
from datetime import date
class Employee:
raise_pct = 1.05 # class variable — shared default
def __init__(self, name: str, salary: float):
self.name = name
self.salary = salary
def apply_raise(self) -> None: # INSTANCE method — gets self
self.salary = round(self.salary * self.raise_pct, 2)
@classmethod
def from_csv(cls, row: str) -> "Employee": # CLASS method — gets cls
name, salary = row.split(",")
return cls(name.strip(), float(salary)) # cls, NOT Employee
@classmethod
def set_raise_pct(cls, pct: float) -> None:
cls.raise_pct = pct # writes to the CLASS
@staticmethod
def is_workday(d: date) -> bool: # STATIC — gets nothing
return d.weekday() < 5
e = Employee.from_csv("Asha Rao, 90000")
print(type(e).__name__, e.name, e.salary)
e.apply_raise()
print("after raise:", e.salary)
Employee Asha Rao 90000.0
after raise: 94500.0
Employee.from_csv("Asha Rao, 90000") reads like what it is. Compare Employee("Asha Rao, 90000", from_csv=True) — which needs a comment, a branch, and an apology.
Why cls and not the class name
This is the part people skip, and it’s the entire reason @classmethod exists as a distinct thing rather than a @staticmethod that happens to return an instance:
class Manager(Employee):
raise_pct = 1.10 # overrides the class variable
def __init__(self, name, salary, reports=None):
super().__init__(name, salary)
self.reports = reports or []
m = Manager.from_csv("Ravi Kumar, 150000")
print("Manager.from_csv returns:", type(m).__name__)
print("m.raise_pct:", m.raise_pct)
m.apply_raise()
print("m.salary after raise:", m.salary)
print("m.reports:", m.reports)
Manager.from_csv returns: Manager
m.raise_pct: 1.1
m.salary after raise: 165000.0
m.reports: []
Manager never defined from_csv. It inherited it — and got a Manager back, with reports initialised and the 10% raise applied. That’s because cls was bound to Manager at the call, so cls(...) called Manager(...).
Hard-code the class name and you silently break every subclass:
@classmethod
def from_str_bad(cls, s):
return Employee(s) # HARD-CODED — ignores cls
@classmethod
def from_str_good(cls, s):
return cls(s)
bad : Employee ← Manager.from_str_bad("x") gave back an Employee!
good: Manager
Manager.from_str_bad(...) returns an Employee. No error, no warning — just an object of the wrong class that will misbehave somewhere far away. In a @classmethod, always use cls. If you’re going to hard-code the class name, you didn’t want a class method.
self (instance method) |
cls (class method) |
|
|---|---|---|
| Bound to | The object | The class (the actual one — subclass-aware) |
| Can read/write instance state | ✅ | ❌ — there’s no instance |
| Can read/write class state | ✅ read; write needs Cls.x = v |
✅ cls.x = v |
| Can create instances | Awkward | ✅ This is the point — return cls(...) |
| Called as | obj.method() |
Cls.method() or obj.method() |
| Inherited behaviour | Normal | cls becomes the subclass automatically |
| Real-world pattern | Looks like | Why it’s a classmethod |
|---|---|---|
| Parse a format | User.from_json(payload) |
Returns cls(...) — subclass-friendly |
| Read a row | Row.from_csv(line) |
Same, one front door per source |
datetime.now() |
stdlib | Builds an instance from the clock |
dict.fromkeys(seq) |
stdlib | Builds a dict — or a subclass of one |
| Change a shared default | Employee.set_raise_pct(1.1) |
Writes cls.raise_pct |
| Register a subclass | cls.registry[name] = cls |
Needs the actual class |
| Singleton / cached factory | Conn.get(host) |
Needs class-level cache + cls(...) |
@staticmethod: no self, no cls — and the honest question
A static method lives inside a class but receives nothing automatic. No self, no cls. It’s a plain function that happens to be typed inside a class block:
@staticmethod
def is_workday(d: date) -> bool:
return d.weekday() < 5
print(Employee.is_workday(date(2026, 7, 15))) # => True (a Wednesday)
print(Employee.is_workday(date(2026, 7, 18))) # => False (a Saturday)
Which raises the question every honest Python article should ask and most don’t: why is that in the class at all?
is_workday doesn’t touch Employee. It takes a date, returns a bool. It would work identically as a module-level function, and a module-level function is simpler, easier to test, easier to import, and easier to reuse from code that has nothing to do with employees.
The honest answer: @staticmethod is a namespacing choice, not a technical one. It says “this helper conceptually belongs to this class, and I’d rather write Employee.is_workday(d) than from utils import is_workday.” That’s a legitimate preference — discoverability is real, and Employee.is_workday autocompletes right where you’re thinking about employees. But it is only a preference, and pretending otherwise is how classes turn into junk drawers of unrelated functions.
There’s exactly one place @staticmethod earns its keep on the merits: when a subclass should be able to override the helper. Put it on the class and polymorphism reaches it; put it in a module and it’s welded shut.
| Question | If yes → |
|---|---|
Does it use self? |
Instance method |
Does it use cls, or return an instance? |
@classmethod |
| Neither, but subclasses may override it? | @staticmethod — real justification |
| Neither, but it’s meaningless outside this class? | @staticmethod — namespacing, fine |
| Neither, and it’s genuinely general-purpose? | A module-level function. Be honest |
| It’s in a class only because “everything must be in a class”? | A module-level function. Python isn’t Java |
That last row deserves emphasis for anyone arriving from a language where free functions don’t exist. In Python, modules are the namespace. A .py file full of functions is a perfectly good, perfectly idiomatic unit of code — math.sqrt is not Math.sqrt() on a class, and that’s not an oversight. If your class has no state and all its methods are static, you have written a module with extra steps. Delete the class.
The method-type comparison
The table to actually memorise:
| Instance method | @classmethod |
@staticmethod |
|
|---|---|---|---|
| Decorator | (none) | @classmethod |
@staticmethod |
| First parameter | self — the object |
cls — the class |
(none) |
| Receives automatically | The instance | The class (subclass-aware) | Nothing |
| Reads instance state | ✅ | ❌ | ❌ |
| Reads class state | ✅ (via self.x) |
✅ (via cls.x) |
❌ (only by hard-coding) |
| Writes class state | needs Cls.x = v |
✅ cls.x = v |
❌ |
| Can build an instance | — | ✅ return cls(...) |
Only by hard-coding |
| Called on | instance (usually) | class or instance | class or instance |
| Subclass-aware | ✅ | ✅ — cls is the real class |
❌ — no class reference |
| Use when | It uses the object’s data | Alternative constructor; class-wide state | A helper that belongs here by name only |
| Typical example | emp.apply_raise() |
Employee.from_csv(row) |
Employee.is_workday(d) |
| Honest alternative | — | — | A module function |
They’re just objects on the class
One more __dict__ reveal, and it ties the whole lesson together:
print(type(Employee.__dict__["apply_raise"])) # => <class 'function'>
print(type(Employee.__dict__["from_csv"])) # => <class 'classmethod'>
print(type(Employee.__dict__["is_workday"])) # => <class 'staticmethod'>
print(Employee.apply_raise) # => <function Employee.apply_raise at 0x102dd1260>
print(e.apply_raise) # => <bound method Employee.apply_raise of <__main__.Employee object at 0x102de5580>>
print(Employee.from_csv) # => <bound method Employee.from_csv of <class '__main__.Employee'>>
print(Employee.is_workday) # => <function Employee.is_workday at 0x102dd16c0>
There’s no magic anywhere. @classmethod and @staticmethod are classes that wrap your function in a descriptor and put it in the class __dict__, exactly where species and tricks were. The only difference is what each one does when it’s looked up:
apply_raiseon the class is a plain function; on an instance it becomes a bound method withselfpre-filled.from_csvis already bound — to the class — even when you reach it through the class. That’sclsbeing pre-filled.is_workdaycomes back as a plain function, from both. Nothing is pre-filled. Which is exactly what “noself, nocls” means, printed out.
Every decorator in this lesson is the same trick: an object in the class dict that decides what a lookup returns.
__slots__: memory, typos, and what it costs
By default, every instance carries a __dict__ — which is why you can bolt any attribute onto any object at any time. That flexibility isn’t free: a dict is a hash table, and you’re paying for one per object. Make ten million objects and it’s the dominant cost.
__slots__ tells Python the attribute set is fixed. It then stores them in a compact array-like structure and skips the per-instance __dict__ entirely:
import sys
class Point: # normal
def __init__(self, x, y):
self.x = x
self.y = y
class SlottedPoint:
__slots__ = ("x", "y") # fixed attribute set — no per-instance __dict__
def __init__(self, x, y):
self.x = x
self.y = y
p, s = Point(1, 2), SlottedPoint(1, 2)
print("Point __dict__:", p.__dict__)
print("Slotted __dict__:", getattr(s, "__dict__", "— none —"))
def deep(obj): # object + its dict, if it has one
total = sys.getsizeof(obj)
d = getattr(obj, "__dict__", None)
return total + sys.getsizeof(d) if d is not None else total
print("Point bytes:", deep(p))
print("Slotted bytes:", deep(s))
print("slot descriptor:", type(SlottedPoint.__dict__["x"]))
Point __dict__: {'x': 1, 'y': 2}
Slotted __dict__: — none —
Point bytes: 344
Slotted bytes: 48
slot descriptor: <class 'member_descriptor'>
344 bytes → 48. Not a rounding error. (Exact numbers vary by version and platform; the ratio is the point.) And notice SlottedPoint.__dict__["x"] is a member_descriptor — a data descriptor sitting on the class, just like property. Same machinery, third appearance.
The second benefit is the one you’ll appreciate sooner than memory:
p.z = 99 # typo — silently accepted, becomes a new attribute
print(p.z) # => 99
s.z = 99 # AttributeError
99
Traceback (most recent call last):
File "demo.py", line 8, in <module>
s.z = 99
^^^
AttributeError: 'SlottedPoint' object has no attribute 'z'
p.z = 99 is how self.balnce = 100 becomes an afternoon: the object cheerfully grows a new attribute, self.balance keeps its old value, and nothing complains until the number is wrong somewhere else entirely. __slots__ turns that silent typo into an immediate, located AttributeError.
__slots__ gives you |
__slots__ costs you |
|---|---|
| Much less memory per instance (no dict) | No new attributes — ever, on any instance |
| Faster attribute access (array slot, not a hash) | Every subclass must also declare __slots__, or the dict returns |
Typo protection — AttributeError instead of a silent new attribute |
No __dict__ → some libraries (pickle, some ORMs, vars()) get unhappy |
| A declared, documented attribute set | Multiple inheritance with non-empty slots raises TypeError |
| — | Can’t have a class attribute with the same name as a slot |
| Gotcha | What happens |
|---|---|
__slots__ = "x" (a string, not a tuple) |
“Works” — but defines one slot, x. Iterating a string gives characters. Always use a tuple |
Subclass without __slots__ |
Instances get a __dict__ back — the saving silently disappears |
__slots__ = ("x",) and x = 0 in the body |
ValueError: 'x' in __slots__ conflicts with class variable |
| Need weak references | Add "__weakref__" to __slots__ |
| Want slots and dynamic attributes | Add "__dict__" to __slots__ (defeats the purpose) |
@dataclass(slots=True) (3.10+) |
Generates __slots__ for you — the pleasant way to get this |
The subclass row is the one that bites in real code. Slots aren’t inherited as a restriction — if any class in the chain omits __slots__, its instances get a __dict__ and every promise above evaporates:
class Sub(SlottedPoint): # no __slots__ declared
pass
sub = Sub(1, 2)
sub.anything = "allowed again"
print(sub.__dict__) # => {'anything': 'allowed again'}
When to use it: you’re creating a lot of small objects (millions of points, rows, events, tokens) and you’ve measured that instance memory matters. That’s it. __slots__ is a performance tool with real ergonomic costs, and using it “for tidiness” on a class you’ll make forty of is a trade you’re losing without noticing. The 90% answer for a small fixed-shape object is @dataclass(slots=True): you get the memory win, the typo protection, and none of the manual bookkeeping.
Hands-on lab
You’ll build fleet.py — a tiny server-inventory model that exercises every idea in this lesson. You’ll ship a plain public attribute, retrofit validation onto it without touching the caller, reproduce the shared-mutable-class-attribute bug and fix it, add both an alternative constructor and a static helper, and finish by printing the namespaces so you can see the whole model laid out.
Standard library only — no pip install — but a virtual environment is the right habit:
mkdir fleet && cd fleet
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
python3 --version # want 3.12+ (Windows users: use `python`)
Step 1 — the caller, and version 1. Create fleet.py:
"""fleet.py — encapsulation, class vs instance state, and the three method types."""
def provision(server) -> str:
"""A CALLER. Written against v1. Never edited again — that is the whole point."""
server.cpu = server.cpu * 2
return f"{server.name} -> {server.cpu} vCPU"
class ServerV1:
"""Version 1, shipped Monday. Plain public attributes. No getters. No setters."""
def __init__(self, name: str, cpu: int):
self.name = name
self.cpu = cpu
What just happened: nothing clever, on purpose. provision reads and writes server.cpu directly. Remember this function — it’s the control in the experiment.
Step 2 — version 2 grows a property. Append:
class Server:
"""Version 2, Thursday: 'cpu must be a positive int, max 128'. Callers unchanged."""
region = "ap-south-1" # CLASS variable — one copy, shared
tags: list[str] = [] # CLASS variable — and a BUG on purpose (step 5)
count = 0 # CLASS variable — a real counter
def __init__(self, name: str, cpu: int):
self.name = name # instance variable
self.cpu = cpu # goes THROUGH the setter below
self.__secret = f"key-{name}" # name-mangled to _Server__secret
Server.count += 1 # write to the CLASS, not to self
@property
def cpu(self) -> int:
"""Number of vCPUs. Was a plain attribute in v1; same syntax, now guarded."""
return self._cpu
@cpu.setter
def cpu(self, value: int) -> None:
if not isinstance(value, int) or isinstance(value, bool):
raise TypeError(f"cpu must be an int, got {type(value).__name__}")
if not 1 <= value <= 128:
raise ValueError(f"cpu must be 1..128, got {value}")
self._cpu = value
@property
def size(self) -> str:
"""COMPUTED / read-only — derived from cpu, so it can never go stale."""
return "small" if self.cpu <= 2 else "medium" if self.cpu <= 8 else "large"
What just happened: cpu is now a property, so self.cpu = cpu in __init__ runs the validation too — for free. The isinstance(value, bool) check is not paranoia: bool is a subclass of int in Python, so isinstance(True, int) is True and server.cpu = True would sail through as 1 vCPU without it.
Step 3 — the alternative constructor and the static helper. Append inside Server:
@classmethod
def from_line(cls, line: str) -> "Server":
"""Build from 'name,cpu' — the real reason @classmethod exists."""
name, cpu = line.split(",")
return cls(name.strip(), int(cpu)) # cls, NOT Server
@staticmethod
def is_valid_name(name: str) -> bool:
"""No self, no cls — it only looks at its argument."""
return name.isascii() and name.islower() and 3 <= len(name) <= 20
def reveal(self) -> str:
return self.__secret # inside the class body, mangling resolves it
What just happened: three method types, side by side. from_line gets cls; is_valid_name gets nothing; reveal gets self. Note is_valid_name is the honest @staticmethod case — it would work fine as a module function, and it’s here for namespacing.
Step 4 — the fix for the shared-mutable bug. Append at module level:
class ServerFixed(Server):
"""The fix for the shared-mutable-class-attribute bug: per-instance list."""
def __init__(self, name: str, cpu: int, tags: list[str] | None = None):
super().__init__(name, cpu)
self.tags = tags if tags is not None else [] # INSTANCE attribute, fresh
What just happened: the fix is the self.tags = [...] assignment, not the subclassing — subclassing is just how we keep both the broken and the fixed version alive in one file so you can compare them. In real code you’d simply write that line in Server.__init__ and delete the class-level tags = [].
Step 5 — the demonstration. Append:
def main() -> None:
print("--- 1. v1: plain public attribute -------------------------")
v1 = ServerV1("web01", 4)
print(" ", provision(v1))
v1.cpu = -9999 # nothing stops this. yet.
print(" v1 happily accepts cpu =", v1.cpu)
print("\n--- 2. v2: same caller, now validated ---------------------")
s = Server("web01", 4)
print(" ", provision(s)) # SAME function. Zero edits.
for bad in (-9999, "8"):
try:
s.cpu = bad
except (TypeError, ValueError) as e:
print(f" blocked cpu={bad!r}: {type(e).__name__}: {e}")
print(" computed size:", s.size, "| read-only?", end=" ")
try:
s.size = "huge"
except AttributeError as e:
print("yes ->", e)
print("\n--- 3. the shared mutable class attribute BUG -------------")
a, b = Server("db01", 8), Server("db02", 8)
a.tags.append("prod")
b.tags.append("staging")
print(" a.tags:", a.tags)
print(" b.tags:", b.tags)
print(" Server.tags:", Server.tags, "| a.tags is b.tags:", a.tags is b.tags)
print(" a.__dict__ has no 'tags':", "tags" not in a.__dict__)
print("\n--- 4. fixed: build the list in __init__ ------------------")
c, d = ServerFixed("db03", 8), ServerFixed("db04", 8)
c.tags.append("prod")
d.tags.append("staging")
print(" c.tags:", c.tags, "| d.tags:", d.tags, "| same?", c.tags is d.tags)
print(" c.__dict__['tags']:", c.__dict__["tags"])
print("\n--- 5. shadowing: writing ALWAYS lands on the instance ----")
print(" before: c.region =", c.region, "| Server.region =", Server.region)
c.region = "ap-south-2"
print(" after : c.region =", c.region, "| Server.region =", Server.region)
print(" d.region =", d.region, "(untouched)")
del c.region
print(" del c.region -> falls back to the class:", c.region)
print("\n--- 6. @classmethod alternative constructor ---------------")
e = Server.from_line("cache01, 16")
print(f" Server.from_line -> {type(e).__name__}(name={e.name!r}, cpu={e.cpu}, size={e.size})")
f = ServerFixed.from_line("cache02, 32")
print(f" ServerFixed.from_line -> {type(f).__name__} <- cls did that, not Server")
print("\n--- 7. @staticmethod helper -------------------------------")
for name in ("web01", "WEB01", "x"):
print(f" is_valid_name({name!r:8}) = {Server.is_valid_name(name)}")
print("\n--- 8. namespaces: class __dict__ vs instance __dict__ ----")
print(" Server.__dict__ (non-dunder):")
for k, v in Server.__dict__.items():
if not k.startswith("__"):
print(f" {k:14} {type(v).__name__}")
print(" s.__dict__:", s.__dict__)
print(" Server.count:", Server.count, "(one counter, on the class)")
print("\n--- 9. name mangling, in the open -------------------------")
print(" s.reveal():", s.reveal())
print(" s._Server__secret:", s._Server__secret)
try:
print(s.__secret)
except AttributeError as e:
print(" s.__secret ->", type(e).__name__ + ":", e)
if __name__ == "__main__":
main()
What just happened: nine experiments, each printing the evidence rather than asserting it.
Step 6 — run it.
python3 fleet.py
--- 1. v1: plain public attribute -------------------------
web01 -> 8 vCPU
v1 happily accepts cpu = -9999
--- 2. v2: same caller, now validated ---------------------
web01 -> 8 vCPU
blocked cpu=-9999: ValueError: cpu must be 1..128, got -9999
blocked cpu='8': TypeError: cpu must be an int, got str
computed size: medium | read-only? yes -> property 'size' of 'Server' object has no setter
--- 3. the shared mutable class attribute BUG -------------
a.tags: ['prod', 'staging']
b.tags: ['prod', 'staging']
Server.tags: ['prod', 'staging'] | a.tags is b.tags: True
a.__dict__ has no 'tags': True
--- 4. fixed: build the list in __init__ ------------------
c.tags: ['prod'] | d.tags: ['staging'] | same? False
c.__dict__['tags']: ['prod']
--- 5. shadowing: writing ALWAYS lands on the instance ----
before: c.region = ap-south-1 | Server.region = ap-south-1
after : c.region = ap-south-2 | Server.region = ap-south-1
d.region = ap-south-1 (untouched)
del c.region -> falls back to the class: ap-south-1
--- 6. @classmethod alternative constructor ---------------
Server.from_line -> Server(name='cache01', cpu=16, size=large)
ServerFixed.from_line -> ServerFixed <- cls did that, not Server
--- 7. @staticmethod helper -------------------------------
is_valid_name('web01' ) = True
is_valid_name('WEB01' ) = False
is_valid_name('x' ) = False
--- 8. namespaces: class __dict__ vs instance __dict__ ----
Server.__dict__ (non-dunder):
region str
tags list
count int
cpu property
size property
from_line classmethod
is_valid_name staticmethod
reveal function
s.__dict__: {'name': 'web01', '_cpu': 8, '_Server__secret': 'key-web01'}
Server.count: 7 (one counter, on the class)
--- 9. name mangling, in the open -------------------------
s.reveal(): key-web01
s._Server__secret: key-web01
s.__secret -> AttributeError: 'Server' object has no attribute '__secret'
Blocks 1 and 2 are the thesis of the lesson, proved: the same provision() function, unedited, drives both versions. V1 accepted -9999; V2 rejects it — and no caller anywhere had to know. That is what “you don’t need getters up front” means in practice, and it’s why @property is the feature that makes the “no private” bet pay.
Block 8 is the payoff for everything else. Look at that column of types: str, list, int, property, property, classmethod, staticmethod, function. Class variables and all three method types are the same kind of thing — entries in the class __dict__. @property and @classmethod aren’t syntax; they’re objects sitting in a dictionary, and the only difference between them is what each one does when you look it up. Next to it, s.__dict__ holds name, _cpu, and _Server__secret — the per-object state, with no cpu key at all (the property owns that name) and the mangled secret in plain view.
Three details worth chasing:
- Blocks 3 and 4 are the same class of bug and fix. In 3, neither instance’s
__dict__hastags, so.append()mutates the single class-level list. In 4,self.tags = []in__init__puts a fresh list per object into the instance dict. Server.countsays 7, not 2, becauseServerFixedinstances runsuper().__init__()which hard-codesServer.count += 1. Every server in the file — including theServerFixedones and the ones built byfrom_line— increments the same counter. Whether that’s what you want is a design decision;type(self).count += 1would giveServerFixedits own tally instead.- Block 6 proves
cls:ServerFixed.from_line(...)returns aServerFixed, complete with its owntagslist, from a method it inherited and never redefined.
Now try these, and predict the output first:
- Change
Server.count += 1toself.count += 1and re-run. Why doesServer.countbecome0? Where did the 7 go? (print(s.__dict__)will show you.) - Delete
tags: list[str] = []from the class body entirely and run. Which block breaks, and what’s the exception? - Rename the
@cpu.settermethod fromcputoset_cpuand run. Predict the exception and which line raises it. (Trap: it’s__init__, not your test.) - Add
s.__secret = "hacked"inmain()then prints.__dict__. Did you overwrite the secret, or create a second attribute? Why? - Add
__slots__ = ("name", "_cpu", "_Server__secret")toServer. What breaks first, and why doesServerFixedstill let you add anything you like?
Common mistakes and troubleshooting
| Symptom / traceback | Cause | Fix |
|---|---|---|
All instances share one list; a.x is b.x is True |
A mutable class attribute: tags = [] in the class body — created once |
Build it per-instance in __init__: self.tags = [] |
A counter stays at 0 while every instance says 1 |
self.count += 1 reads the class attr but writes an instance attr |
Cls.count += 1 (or type(self).count += 1 for per-subclass tallies) |
A class attribute “didn’t change” after obj.x = v |
Writes always land on the instance; you shadowed it | Cls.x = v to change it for everyone; del obj.x to un-shadow |
AttributeError: property 'x' of 'C' object has no setter |
No @x.setter — or the setter method isn’t named x |
Name the setter method x, decorated with @x.setter |
AttributeError: can't set attribute (3.10 and older) |
Same as above, worded before the 3.11 overhaul | Same fix |
RecursionError: maximum recursion depth exceeded |
self.x = value inside x’s setter → the setter calls itself |
Assign to the backing field: self._x = value |
RecursionError from the getter |
return self.x inside x’s getter |
return self._x |
AttributeError: 'C' object has no attribute '__x' |
Name mangling — it’s stored as _C__x; mangling only works inside the class body |
Use _C__x, or expose a property. Better: use _x, not __x |
A subclass “lost” the parent’s __x |
Both mangled — _Parent__x and _Child__x are different attributes |
That’s the feature. Use _x if you want it shared |
TypeError: C.f() missing 1 required positional argument on a @staticmethod |
The static method still declares self — nothing fills it |
Delete self, or make it a @classmethod/instance method |
A @staticmethod silently eats your first argument |
Same bug, but you passed enough args, so self swallowed one |
Delete self from the signature |
Prints <bound method C.area of <...>> instead of a number |
Forgot @property — you printed the method object |
Add @property, or call it: c.area() |
A @classmethod returns the parent class from a subclass |
Hard-coded return Employee(...) instead of return cls(...) |
Use cls(...). Always |
AttributeError: 'C' object has no attribute 'z' after adding __slots__ |
z isn’t in __slots__ — that’s the typo protection working |
Add it to __slots__, or fix the typo |
__slots__ saved no memory |
A subclass omitted __slots__, so instances got a __dict__ back |
Declare __slots__ on every class in the chain |
ValueError: 'x' in __slots__ conflicts with class variable |
You declared x in __slots__ and assigned x = 0 in the body |
Drop one. Slots and class attributes can’t share a name |
TypeError: cpu must be an int, got bool never fires for True |
bool subclasses int, so isinstance(True, int) is True |
Add an explicit isinstance(value, bool) rejection |
Four of these are worth extra words, because they cost the most hours.
1. The shared mutable class attribute. This is the big one, and the reason it’s so expensive is that the symptom appears nowhere near the cause. You wrote tags = [] in the class body six weeks ago; today, a user reports seeing another user’s data. Nothing crashed. Nothing logged. The class body ran once, made one list, and every instance has been quietly appending to it ever since — and because a.tags reads fine and .append() mutates rather than assigns, no instance ever gets its own copy. The two-second diagnostic is print(a.tags is b.tags); if that says True and you expected two lists, you’ve found it. The rule that prevents it forever: only immutable values go in the class body. Constants and defaults, yes. Anything with .append() or [k] = v, never.
2. The counter that doesn’t count. self.count += 1 is one of the purest traps in the language, because it looks exactly right and fails silently. Expand it and the bug is obvious: self.count = self.count + 1. The read finds the class attribute (0); the write goes to the instance. Every object independently computes 0 + 1 and stores 1 on itself; the class attribute is never touched by anyone. And c1.count returning 1 makes it look like it’s working — you only notice when the total is wrong. Every augmented assignment on a class attribute through self is this bug. Name the class explicitly.
3. Infinite recursion in a property. A classic five-minute panic:
@property
def balance(self):
return self._balance
@balance.setter
def balance(self, value):
self.balance = value # BUG: this IS the setter. It calls itself.
Traceback (most recent call last):
File "demo.py", line 13, in <module>
a = Account(100)
^^^^^^^^^^^^
File "demo.py", line 3, in __init__
self.balance = balance
^^^^^^^^^^^^
File "demo.py", line 11, in balance
self.balance = value # BUG: calls this setter again, forever
^^^^^^^^^^^^
[Previous line repeated 995 more times]
RecursionError: maximum recursion depth exceeded
self.balance = value is the thing that triggers the setter. So the setter calls the setter calls the setter, ~1000 frames deep, until Python gives up. Read that traceback shape: the same line repeating with [Previous line repeated 995 more times] is the unmistakable signature of accidental recursion, and it tells you exactly which line to look at. The fix is one character: assign to the backing field, self._balance = value. The convention exists precisely so the property and its storage have different names — the public name (balance) belongs to the property on the class; the private-ish one (_balance) is the real slot in the instance dict. The same trap catches getters (return self.balance → recursion) and it’s why “just use the same name” doesn’t work.
4. @property forgotten, or added too late. Forget the decorator and print(c.area) prints <bound method Circle.area of <__main__.Circle object at 0x10095aff0>>. Whenever you see <bound method ...> where you expected a value, you either forgot @property or forgot (). The subtler direction: converting an existing method get_area() into a property is a breaking change — every obj.get_area() becomes obj.get_area()(), calling the returned value. Going the other way (plain attribute → property) is free, which is exactly why you start with the plain attribute.
Cheat-sheet
| Syntax | What it does |
|---|---|
self.x = v |
Public attribute. The default. Start here |
self._x = v |
Internal by convention. Zero enforcement. Skipped by import * |
self.__x = v |
Name-mangled to _Class__x. Anti-collision, not privacy |
self.__x__ |
Reserved for Python. Not mangled. Don’t invent your own |
obj.__dict__ / vars(obj) |
This object’s own attributes — the diagnostic |
Cls.__dict__ |
Class vars + methods + properties (a read-only mappingproxy) |
'x' in obj.__dict__ |
Is x shadowed on this instance, or coming from the class? |
Cls.__dict__['x'] |
The raw object — reveals property / classmethod |
dir(obj) |
Every reachable name: instance + class + ancestors |
type(obj).__mro__ |
The exact class search order for a read |
@property |
obj.x → calls the getter. Retrofit onto a plain attribute |
@x.setter |
obj.x = v → calls the setter. Must be named x |
@x.deleter |
del obj.x → calls the deleter |
| getter only | Read-only property → AttributeError on assign |
self._x = value in the setter |
The backing field. Using self.x → RecursionError |
@functools.cached_property |
Compute once, cache in obj.__dict__. No invalidation |
x = 0 in the class body |
Class variable — one copy, shared by all instances |
x = [] in the class body |
The bug. One shared list. Use self.x = [] in __init__ |
obj.x = v |
Always writes to the instance — shadows the class attribute |
obj.x += 1 on a class attr |
The counter bug — reads class, writes instance |
Cls.x += 1 |
The correct shared counter |
type(self).x += 1 |
Per-subclass counter (a different, deliberate choice) |
del obj.x |
Removes the instance attr → un-shadows the class one |
def f(self) |
Instance method — gets the object |
@classmethod def f(cls) |
Class method — gets the class. return cls(...) |
@staticmethod def f() |
Static — gets nothing. Ask: should this be a module function? |
Cls.from_x(data) |
The alternative constructor pattern — the reason for @classmethod |
__slots__ = ("x", "y") |
No per-instance __dict__: less memory, typo protection |
@dataclass(slots=True) |
The pleasant way to get __slots__ (3.10+) |
Interview and exam questions
Q: Does Python have private attributes?
A: No. There is no access control at all. There’s _x (a naming convention meaning “internal, may change” — zero runtime effect), and __x (which triggers name mangling to _ClassName__x — a rename, not a lock; you can read it as obj._ClassName__x). The philosophy is “we’re all consenting adults here”: mark what’s internal, trust colleagues to honour it. Python enforces validity (via @property), not access.
Q: What does name mangling actually do, and what’s it for?
A: Inside a class body, the compiler rewrites self.__x to self._ClassName__x. Its real purpose is preventing accidental attribute collisions between a base class and a subclass — if Base and Child both use self.__id, they get _Base__id and _Child__id and never clobber each other. It is not a security feature. Because mangling only happens inside a class body, obj.__x from outside raises AttributeError — that’s a name that genuinely doesn’t exist, not an access violation.
Q: Why doesn’t Python need Java-style getters and setters?
A: Because @property lets you convert a plain public attribute into a getter/setter later, without changing any caller. In Java a public field can never become a method call, so you must write the boilerplate on day one against a rule you may never need. In Python acct.balance and acct.balance = x keep working identically after you add the property, so the pre-emptive boilerplate buys nothing. The corollary: don’t write a pass-through @property up front either — start with the plain attribute and add the property the day you have a rule.
Q: What’s the difference between a class variable and an instance variable?
A: A class variable is assigned in the class body and exists once, on the class (in Cls.__dict__), shared by every instance. An instance variable is assigned on self and exists once per object (in obj.__dict__). Reading obj.x checks the instance then falls back to the class; writing obj.x = v always creates/updates the instance attribute and never touches the class.
Q: What does this print, and why?
class Dog:
tricks = []
def __init__(self, name):
self.name = name
a, b = Dog("Rex"), Dog("Bella")
a.tricks.append("roll over")
print(b.tricks)
A: ['roll over']. tricks = [] is a class variable — one list, created once when the class body ran. a.tricks reads through to the class and .append() mutates that shared list in place (no assignment happens, so a never gets its own copy — 'tricks' not in a.__dict__). a.tricks is b.tricks is True. Fix: self.tricks = [] in __init__. It’s the same root cause as the mutable default argument (def f(x=[])): the defining code runs once.
Q: Why does this counter stay at 0?
class C:
count = 0
def __init__(self):
self.count += 1
A: self.count += 1 is self.count = self.count + 1. The right side reads — misses the instance, finds the class attribute 0. The left side writes — and writes always go to the instance. So every object computes 0 + 1 and stores 1 in its own __dict__; C.count is never touched. Fix: C.count += 1 (or type(self).count += 1 if you want each subclass to keep its own tally).
Q: @classmethod vs @staticmethod — what’s the difference and when do you use each?
A: A class method receives the class as cls; a static method receives nothing. Use @classmethod for alternative constructors (User.from_json(payload) → return cls(...)) and for reading/writing class-level state — cls is subclass-aware, so Manager.from_json() returns a Manager for free. Use @staticmethod for a helper that uses neither self nor cls but conceptually belongs to the class — and be honest that it’s a namespacing choice: it would work identically as a module-level function, which is often the better answer. The one real technical justification for @staticmethod is letting subclasses override the helper.
Q: Why must a @classmethod use cls(...) rather than the class name?
A: Because cls is bound to the actual class at call time, so an inherited class method builds the right type. Manager.from_csv(row) returns a Manager only if the body says return cls(...). Hard-code return Employee(...) and every subclass silently gets an Employee back — no error, no warning, just an object of the wrong class failing somewhere far away.
Q: Explain the attribute lookup order for obj.x.
A: Python searches type(obj).__mro__ first for a data descriptor (something with both __get__ and __set__ — a property is exactly this); if found, it wins and __dict__ is never consulted. Otherwise it checks obj.__dict__. Otherwise it uses the class/MRO attribute it found (a plain value, or a non-data descriptor like a function → bound method, or cached_property). Otherwise __getattr__, then AttributeError. Writes are simpler: obj.x = v checks the MRO for a data descriptor and calls its __set__; otherwise it writes to obj.__dict__. Never to the class.
Q: Why does this raise RecursionError?
@balance.setter
def balance(self, value):
self.balance = value
A: self.balance = value is precisely what invokes the balance setter — so the setter calls itself, ~1000 frames deep, until RecursionError: maximum recursion depth exceeded. Assign to the backing field instead: self._balance = value. The _x naming convention exists so the property (public name, on the class) and its storage (private-ish name, in the instance dict) never collide. The same trap catches getters that return self.x.
Q: What is __slots__, and what does it cost?
A: It declares a fixed attribute set, so instances get compact slots instead of a per-instance __dict__. You gain a lot of memory (roughly 344 → 48 bytes for a 2-attribute object), slightly faster access, and typo protection — p.z = 99 raises AttributeError instead of silently creating junk. You pay: no new attributes ever; every subclass must also declare __slots__ or the __dict__ comes back and the saving evaporates; no __dict__ upsets some libraries; and a slot can’t share a name with a class attribute (ValueError). Use it when you’ve measured that millions of small objects cost you memory — or just use @dataclass(slots=True).
Q (coding): Give Temperature a celsius attribute that rejects anything below absolute zero, plus a read-only fahrenheit, plus a constructor that takes Fahrenheit.
A:
class Temperature:
def __init__(self, celsius: float):
self.celsius = celsius # goes through the setter
@property
def celsius(self) -> float:
return self._celsius # backing field — NOT self.celsius
@celsius.setter
def celsius(self, value: float) -> None:
if value < -273.15:
raise ValueError(f"below absolute zero: {value}")
self._celsius = value
@property
def fahrenheit(self) -> float: # computed, read-only, never stale
return self._celsius * 9 / 5 + 32
@classmethod
def from_fahrenheit(cls, f: float) -> "Temperature":
return cls((f - 32) * 5 / 9) # cls → subclass-friendly
t = Temperature(25)
print(t.fahrenheit) # => 77.0
print(Temperature.from_fahrenheit(212).celsius) # => 100.0
t.celsius = -300 # ValueError: below absolute zero: -300
Four things are being tested: the setter validates and __init__ gets that for free by assigning through it; the backing field is _celsius (using self.celsius in the setter would recurse); fahrenheit is computed so it can never drift out of sync with celsius; and the alternative constructor uses cls, not Temperature.
Key takeaways
- Python has no
private, and that’s a bet —@propertyis why it pays. Ship a plain public attribute. The day a rule arrives, add@property+@x.setterand not one caller changes:acct.balance = 500still works. Java needs getters up front because a field can’t become a method later; Python doesn’t, so don’t write them — and don’t write pass-through properties up front either. _xis a note;__xis a rename; neither is a lock._xhas zero runtime effect (beyondimport *).__xis name-mangled to_Class__xto prevent subclass collisions — you can still read it, so never put a secret behind it. The things that actually enforce anything (@propertyvalidation, read-only properties,__slots__) enforce validity, not access.- An attribute read is a search; a write is not.
obj.xchecks the class/MRO for a data descriptor first (that’s how@propertyintercepts), then the instance dict, then the class again.obj.x = vsearches nothing — it writes toobj.__dict__, always. Every surprise in this lesson falls out of those two sentences. - Class variables exist once; instance variables exist per object.
Cls.__dict__vsobj.__dict__— print them, don’t guess.'x' in obj.__dict__is the diagnostic that answers “why is this value wrong?” - A mutable class attribute is a bug, not a style choice.
tags = []in the class body makes one list shared by every instance forever —a.tags is b.tagsisTrue. Same root cause asdef f(x=[]): the defining code runs once. Constants and defaults on the class; anything with.append()in__init__. self.count += 1on a class attribute silently does nothing. It reads the class and writes the instance, so the counter stays at 0 while every object claims1. Say which namespace you mean:Cls.count += 1.@classmethodis for alternative constructors —User.from_json(payload)returningcls(...). Usecls, never the hard-coded class name, or every subclass gets the wrong type back with no error.@staticmethodis a namespacing choice, not a technical one. If it uses neitherselfnorcls, ask honestly whether it should be a module-level function — usually yes. Python isn’t Java; a module full of functions is idiomatic. The one real justification is letting subclasses override it.__slots__trades flexibility for memory and typo protection — and only pays when you’ve measured that millions of objects cost you. Every subclass must declare it too, or the__dict__quietly returns. Prefer@dataclass(slots=True).