Python Lesson 17 of 71

OOP Part 3 — Encapsulation, Class vs Instance Variables, Static & Class Methods

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:

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_underscorename 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:

  1. caller(b) still works. acct.balance and acct.balance = x are unchanged at every call site in the world. The public API is byte-for-byte identical.
  2. b.balance = -9999 now raises. The rule is enforced — including inside __init__, for free, because self.balance = balance goes through the same setter.
  3. b.__dict__ proves the mechanism: there is no balance key. The real storage is _balance; the name balance now belongs to a property object 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 AttributeErrorread-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-only mappingproxy, not a plain dict; instance __dict__s are ordinary writable dicts.

The read path, the write path, and the diagram

Two rules govern everything:

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.

Python attribute lookup drawn left to right: a caller reading s.cpu and writing s.cpu = 8, a property gate on the class that gets first refusal because a property is a data descriptor and runs fget/fset validation, the per-object instance dict holding name and _cpu, the single shared class dict holding region, tags and count plus the methods and properties, and a final result of either a returned value or an AttributeError

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:

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.count0. 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 pointreturn 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:

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 protectionAttributeError 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:

Now try these, and predict the output first:

  1. Change Server.count += 1 to self.count += 1 and re-run. Why does Server.count become 0? Where did the 7 go? (print(s.__dict__) will show you.)
  2. Delete tags: list[str] = [] from the class body entirely and run. Which block breaks, and what’s the exception?
  3. Rename the @cpu.setter method from cpu to set_cpu and run. Predict the exception and which line raises it. (Trap: it’s __init__, not your test.)
  4. Add s.__secret = "hacked" in main() then print s.__dict__. Did you overwrite the secret, or create a second attribute? Why?
  5. Add __slots__ = ("name", "_cpu", "_Server__secret") to Server. What breaks first, and why does ServerFixed still 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.xRecursionError
@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 protectionp.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

pythonoopencapsulationpropertyclassmethodstaticmethodclass-variablesinstance-variablesname-manglingslotsdescriptorsgetters-settersdunder-dictintermediate
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments