account.balance = -5000. Nothing stopped that line — and now every method that trusted the balance is quietly wrong. Attributes are honest about being public... which means anyone can break your object's rules with one assignment. Time to give objects a way to say 'not like that'.
What you'll learn
What
Encapsulation: separating an object's public API from its internals. Python's tools are conventions (_single underscore = internal), name mangling (__double), and @property — which lets attribute syntax run code, so reads can compute and writes can validate.
Why
Objects exist to maintain invariants — a balance that matches its history, a temperature above absolute zero, an email that contains @. Encapsulation makes those rules enforceable at the boundary, and @property lets you add enforcement LATER without breaking a single caller — Python's answer to why it has no getter/setter boilerplate.
Where it's used
Validated domain models, computed attributes (df.shape is one!), caching, deprecating fields gracefully, and every library whose attributes 'just know' derived values.
Where this runs in production
It looks like stored data but computes from the internals on every read — and df.shape = (2, 2) raises, because there's no setter. You've been using @property since your first DataFrame.
FastAPI request models reject bad data the instant a field is set — industrial-strength setter validation, the exact pattern this lesson hand-builds.
The requests library's Session keeps _-prefixed internals it can refactor freely — a decade of internal changes, near-zero breakage, because the underscore contract held.
class Account:
def __init__(self, owner, balance):
self.owner = owner # public API
self._balance = balance # internal: touch via methods
self._history = [] # internal
def deposit(self, amount):
self._balance += amount
self._history.append(amount)
def balance_report(self):
return f'{self.owner}: ${self._balance}'The underscore splits the class into two zones: owner and the methods are the public contract; _balance and _history are implementation the class may restructure at will. Callers who type the underscore are announcing they accept the breakage risk.
A restaurant has a dining room (public API) and a kitchen (_internals). The 'Staff Only' sign on the kitchen door isn't a lock — you CAN barge in — but everyone understands: rearrange the kitchen and your dinner is your problem. @property is the waiter: you ask for 'the bill' as if it were a thing sitting on a shelf, but the waiter computes it fresh from the kitchen's records. And a property SETTER is the waiter checking your credit card before accepting it — same simple gesture from your side, validation on theirs.
Decorate a no-argument method with @property and it's READ as an attribute: obj.total, no parentheses — but your code runs. Perfect for derived values (total from items, area from radius, shape from data) because they can never go stale: computed on every read, always consistent with the source state. To callers it's indistinguishable from plain data — that's the point.
class Order:
def __init__(self):
self._items = [] # (name, price)
def add(self, name, price):
self._items.append((name, price))
@property
def total(self):
return round(sum([p for _, p in self._items]), 2)
o = Order()
o.add('mug', 14.0)
o.add('tee', 22.5)
print(o.total) # attribute syntax — the method runs
# o.total = 99 # AttributeError: no setter — read-only!total is always right because it's never stored — no 'forgot to update the cached total' bug can exist. And with no setter defined, assignment raises: read-only attributes for free.
@total.setter (named after the property) intercepts ASSIGNMENT: obj.celsius = -300 runs your checks and raises before bad state lands. The idiom pairs a property `celsius` with storage `_celsius`: the setter validates then writes the underscore name. Note __init__ should assign self.celsius = value (through the setter!) so construction is validated by the same gate. Result: invalid state is impossible to reach through the public name.
Attribute syntax promises attribute COST — callers will read obj.total in loops and f-strings without a second thought. A property that hits a database, sleeps, or mutates state betrays that promise (and a property that changes state on READ is a command-query violation in disguise). Rule: properties compute quickly from in-memory state; anything slower or effectful stays an explicit method — fetch_total() warns the caller with its parentheses.
With @property + setter, attribute syntax routes through your code both ways. Click each node.
Mark which parts of a class are contract vs implementation.
Callers use tick() and elapsed_label(); _ticks is the class's own business. Tomorrow it could store timestamps instead — no caller notices, because none (politely) touched the underscore.
2 ticks
Your task
Build a Product with a validated price: __init__(name, price) assigns through the property; the price setter rejects negatives with ValueError('price cannot be negative'); storage lives in _price; add a read-only property display returning '<name>: $<price formatted to 2dp>'. Demo: create one, reprice it, attempt a negative price (catch and print the error), print display.
price cannot be negative mug: $12.50
Write your solution in the editor on the right, then hit Run.
What does a single leading underscore (self._cache) mean in Python?
What does @property change about a method?
A property `total` has a getter but NO setter. What does obj.total = 100 do?
What's the structural fix?
Class Circle: __init__(r) assigns through a validated property (radius must be > 0, else ValueError('radius must be positive')); read-only property area = round(3.14159 * r * r, 2). Create Circle(2), print area; try Circle(-1) and print the caught error. Expected: 12.57 radius must be positive
What does self.__token (double underscore) actually do?
What design rule was broken?
Python has no private keyword. How does encapsulation actually work, and why is it designed that way?
Through convention plus one mechanical assist. A single leading underscore marks an attribute or method as internal — nothing enforces it, but linters flag outside access, `from module import *` skips such names, and reviewers treat violations as contract breaches. Double leading underscores add name mangling (rewritten to _ClassName__attr), whose real purpose is preventing accidental name collisions between a base class and its subclasses, not secrecy — the mangled name is plainly visible in vars(). The philosophy ('consenting adults'): hard privacy mostly obstructs debugging, testing, and emergency patches, while a clear marker achieves the actual goal — communicating what's stable API versus refactorable implementation. The enforcement that matters is at the WRITE boundary anyway, and @property provides that: computed reads and validating setters give Python real invariant protection exactly where invariants live.
Why does Python code avoid Java-style getX()/setX() methods, and what replaces them?
Because @property makes the boilerplate unnecessary WITHOUT giving up the option of behavior later. Java bakes getters/setters in from day one defensively — changing a public field to a method breaks every caller, so you pay the ceremony up front just in case. Python's property decorator decouples syntax from mechanism: obj.email is attribute syntax whether it's a plain attribute or a property running validation code, so you ship the plain attribute first and convert to a property — same name, zero caller changes — only when a real need arrives (validation, normalization, derivation, deprecation warnings). That migration path is the whole argument: uniform access means you never pay for flexibility you don't use. The caveat that comes with it: properties must stay cheap and side-effect-free, because attribute syntax tells callers 'this is data' — an expensive or mutating property betrays the syntax and belongs behind an explicit method call.
How do you design a class so invalid states are unrepresentable? Walk through the tools.
Layer the defenses at the boundary. First, funnel all mutation through a narrow surface: storage is underscore-internal, and state changes go through commands (record(), deposit()) or property setters — never raw attribute pokes. Second, validate in the setter and route construction through it: __init__ assigns self.celsius = value (the public name), so the same gate checks initial values and later updates — no separate constructor validation to drift out of sync. Third, make derived values computed properties rather than stored fields (total from _items, mean from _readings): computed state cannot desync from its source, eliminating the entire 'forgot to update the cache' bug family. Fourth, make read-only things read-only by omitting setters — df.shape-style. Finally, raise early with specific exceptions (ValueError with the offending value in the message) so bad data fails at the boundary, not three functions downstream — which is the bridge to the next module: error handling is how these gates communicate.
Common Mistakes to Avoid
1) Bypassing your own gates — writing self._price = x from other methods when the setter has the validation; go through the public name. 2) Storing derived values (self.total) that mutation paths must remember to update — compute them as properties. 3) Infinite recursion: the getter returning self.price instead of self._price calls itself forever. 4) Expensive/side-effecting properties — attribute syntax promises attribute cost. 5) __double underscores as 'stronger private' — it's clash protection for inheritance, not security. 6) Forgetting __init__ should assign through the property so construction validates too.
Ask the AI Tutor
Try these prompts in the AI Tutor panel: • 'Quiz me: plain attribute, property, or method — for these eight fields?' • 'Walk me through the getter-recursion bug and why _name breaks the loop.' • 'Show the attribute→property migration on a class I paste.' • 'Explain name mangling with a subclass collision example.' • 'Interview mode: ask me why Python skips getters/setters and grade my answer.'
Glossary
Encapsulation — separating public API from internals. _single underscore — 'internal' by convention; unenforced. __double underscore — name mangling to _ClassName__attr; clash protection. @property — decorator making a method readable as an attribute. Getter/setter — the property's read and (optional) write functions. @x.setter — decorator registering the validating write path. Read-only property — getter without setter; assignment raises. Derived value — computed from other state; wants a property, not storage. Invariant — a rule the object's state must always satisfy. Uniform access — same syntax for stored and computed attributes.
Recommended Resources
• Docs: the built-in property() reference — the decorator trio (getter/setter/deleter) in one page. • Read: PEP 8's 'Designing for Inheritance' section — the official word on underscores and when to use properties. • Practice: take your Inventory class from Classes & Objects and make stock read-only from outside, with a computed total_items property. • Next in DSM: your setters raise ValueError — but what happens to a raised error, and how do callers catch it? Special Methods completes the OOP module first: __repr__, __eq__, __len__ — making your objects feel native.
Recap
✓ _underscore marks internals — a contract, not a lock; respect it both ways. ✓ @property runs code behind attribute syntax — derived values computed fresh, never stale. ✓ No setter = read-only; a setter = validation at the boundary, construction included. ✓ Public name gates, underscore name stores — and getters must read the underscore or recurse. ✓ Properties stay cheap and pure; slow or effectful work is a visible method call. ✓ Plain attributes first; upgrade to properties later with zero caller changes. Next up: Special Methods. Your classes work — now make them feel NATIVE: __repr__ for honest printing, __eq__ for ==, __len__ for len(), and the dunder protocol behind every Python operator.
Run your code to see the output here.