What you'll learn
- Keep an object's state valid by changing it only through methods
- Mark internal attributes with a leading underscore and understand what it does and does not do
- Expose computed values and validated setters with
@property - Tell class attributes, shared by all instances, from instance attributes
Concept
Encapsulation is the first pillar and the most practical one: an object should control how its own data changes. If any code anywhere can write account.balance = -500, then no method in the class can trust the balance. The fix is to route every change through a method that checks it, deposit and withdraw, and to signal that the raw attribute is not for outside use.
Python signals that with a leading underscore: self._balance. It is a convention, not a lock. Other code can still read and write _balance, but every Python developer reads the underscore as "internal, do not touch from outside", and linters and editors honor it. A double underscore, __balance, goes further and mangles the name so accidental access from outside fails; it is rarer and mostly used to avoid clashes in subclasses. In practice, one underscore is the everyday tool.

Sometimes you want attribute syntax with method behavior. A property does that. Decorating a method with @property makes account.balance call it without parentheses, so you can compute a value on demand, like full_name from first and last, or expose a read-only view of _balance. Adding a @balance.setter method lets account.balance = 200 run validation before storing. To callers it looks like a plain attribute; inside, you are in control. This is also how you add validation later to an attribute that started out plain, without changing any caller.
| You want | Write | Callers use |
|---|---|---|
| computed value, no storage | @property def area(self) |
shape.area |
| read-only view of internal state | @property def balance(self) |
acct.balance (no assignment) |
| validated assignment | @balance.setter def balance(self, v) |
acct.balance = 200 |
| an action with side effects | a normal method | acct.withdraw(30) |
Attributes defined directly in the class body, outside any method, are class attributes: one value shared by every instance, read through self or through the class. They are right for constants and defaults, interest_rate = 0.02, and for counters that span all instances. Assigning self.interest_rate = 0.05 on an instance creates an instance attribute that shadows the shared one for that object only, which is the intended way to override a default. The trap is a mutable class attribute like a list: every instance shares the same list, and one instance's append shows up in all of them.

Code
Routing changes through methods, and what the underscore protects against. The invalid deposit is refused; the attribute itself is still reachable, which is why the convention matters.
# encapsulated.py
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self._balance = balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("deposit must be positive")
self._balance += amount
def withdraw(self, amount):
if amount > self._balance:
raise ValueError("insufficient funds")
self._balance -= amount
def __repr__(self):
return f"BankAccount({self.owner!r}, {self._balance})"
acct = BankAccount("ada", 100)
acct.deposit(50)
try:
acct.deposit(-20)
except ValueError as err:
print("refused:", err)
print(acct)
print(acct._balance, "<- reachable, but the underscore says don't")
refused: deposit must be positive
BankAccount('ada', 150)
150 <- reachable, but the underscore says don'tProperties: a computed value, a read-only view, and a validated setter. To the caller they all look like attributes.
# properties.py
class Rectangle:
def __init__(self, width, height):
self._width = width
self._height = height
@property
def area(self):
return self._width * self._height
@property
def width(self):
return self._width
@width.setter
def width(self, value):
if value <= 0:
raise ValueError("width must be positive")
self._width = value
r = Rectangle(3, 4)
print(r.area)
r.width = 10
print(r.width, r.area)
r.area = 100
12
10 40
Traceback (most recent call last):
File "properties.py", line 25, in <module>
r.area = 100
^^^^^^
AttributeError: property 'area' of 'Rectangle' object has no setterThe setter refusing a bad value, so the object can never hold an invalid width, however it is assigned.
# setter_validation.py
class Rectangle:
def __init__(self, width, height):
self.width = width # goes through the setter, so it is validated too
self._height = height
@property
def width(self):
return self._width
@width.setter
def width(self, value):
if value <= 0:
raise ValueError(f"width must be positive, got {value}")
self._width = value
r = Rectangle(3, 4)
for attempt in (5, 0, -2):
try:
r.width = attempt
print("width is now", r.width)
except ValueError as err:
print("rejected:", err)
width is now 5
rejected: width must be positive, got 0
rejected: width must be positive, got -2Class attributes versus instance attributes, including the shared-list trap and its fix.
# class_attrs.py
class BankAccount:
interest_rate = 0.02
count = 0
def __init__(self, owner):
self.owner = owner
BankAccount.count += 1
ada = BankAccount("ada")
linus = BankAccount("linus")
linus.interest_rate = 0.05
print(ada.interest_rate, linus.interest_rate, BankAccount.interest_rate)
print(BankAccount.count)
class Bad:
tags = [] # shared by every instance: a trap
def add(self, tag):
self.tags.append(tag)
class Good:
def __init__(self):
self.tags = [] # one list per instance
def add(self, tag):
self.tags.append(tag)
a, b = Bad(), Bad()
a.add("x")
print("Bad:", b.tags)
c, d = Good(), Good()
c.add("x")
print("Good:", d.tags)
0.02 0.05 0.02
2
Bad: ['x']
Good: []Try it yourself
Create thermostat.py with a Thermostat class that stores _target in Celsius. Add a target property with a setter that only accepts values between 5 and 30, a read-only target_fahrenheit property computed from it, and a class attribute unit = "C". Show a valid assignment, a rejected one, and the computed conversion. Then add a class attribute instances = 0 that counts how many thermostats have been created.
Common mistakes
- Treating the underscore as security. It is a signal to other developers, not a lock; the point is that the methods keep the state valid.
- Writing getters and setters for everything. Plain attributes are fine; add a property only when you need computation or validation, and callers will not notice the change.
- A mutable class attribute as a default.
items = []in the class body is one list shared by all instances; create it in__init__instead.
What's next
- Change state only through methods, and mark internal attributes with a leading underscore.
@propertygives attribute syntax to computed or validated values, and can be added later without changing callers.- Class attributes are shared defaults; instance attributes belong to one object; never use a mutable class attribute as a per-instance default.
Your objects now guard their own state. Next you will learn the two other kinds of method a class can have: class methods and static methods.