Python @property: Getters, Setters and Private Attributes
Python has no private keyword: one underscore marks internal state, two trigger name mangling. How @property validates and computes values, and what slots do.
- Course: Python study plan
- Module: Classes and objects
- Kind: Lesson
- Reading time: 13 min
- Runtime: CPython 3.11
What does @property do in Python?
@property turns a method into an attribute read without parentheses, so a value can be computed, validated or made read-only while callers still write c.radius. A matching @radius.setter runs on assignment and @radius.deleter on del. Because a plain attribute can become a property later without changing any caller, Python code does not need Java-style getters and setters.
Lesson
Python has no private keyword and does not want one. Encapsulation is done by convention — a leading underscore means "not part of the interface" — and enforced socially rather than by the compiler, on the principle that consenting adults may reach inside an object if they accept the consequences. What Python does offer is @property, which lets an attribute look like plain data while being computed, validated or read-only, so that a class can start with public attributes and add control later without changing a single caller. This lesson covers the naming conventions and what name mangling actually does, @property with setters and deleters, computed and cached attributes, read-only attributes, and __slots__.
The underscore conventions
| Name | Meaning |
|---|---|
balance | public: part of the interface, safe to use |
_balance | internal: use at your own risk; not imported by from m import * |
__balance | name-mangled: stored as _ClassName__balance to avoid clashes in subclasses |
__init__ | a dunder: a protocol method Python calls; never invent your own |
_name is the working convention and the only one most code needs. __name (two leading, no trailing underscores) is rewritten by the compiler to _ClassName__name; it is not privacy — obj._Account__balance reads it — but a way to keep a subclass's attribute of the same name from colliding with the base class's. Reserve it for that case.
Start with plain attributes
class Circle:
def __init__(self, radius):
self.radius = radius
Java-style getters and setters (get_radius, set_radius) have no place in Python, because a public attribute can be turned into a property later with no change to callers. Write the plain attribute; add control when there is a reason.
@property
class Circle:
def __init__(self, radius):
self.radius = radius # goes through the setter below
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value < 0:
raise ValueError("radius must be non-negative")
self._radius = value
@property
def area(self): # computed; read-only because there is no setter
return 3.141592653589793 * self._radius ** 2
c = Circle(2)
c.radius = 3 # setter runs: validated
c.area # 28.27… — an attribute in syntax, a method in fact
c.area = 1 # AttributeError: can't set attribute
@property turns a method into an attribute read; @name.setter adds the write; @name.deleter handles del c.radius. The stored value lives in _radius; the public name radius is the property. Note that __init__ assigns through the property, so validation applies from the start. Three uses: validation (reject bad values), computed attributes (area from radius, kept consistent automatically), and read-only attributes (a property with no setter).
A property should be cheap and side-effect-free — a reader sees c.area and assumes a field read. Anything expensive or that changes state is a method (c.compute_area()), so the call is visible.
Cached computed attributes
functools.cached_property computes once on first access and stores the result in the instance dict:
from functools import cached_property
class Report:
def __init__(self, rows):
self.rows = rows
@cached_property
def total(self):
return sum(r.amount for r in self.rows)
Right for values that are expensive and do not change after construction; wrong if rows can be mutated later, because the cached value would be stale. del report.total clears the cache.
Read-only and immutable objects
A property without a setter gives one read-only attribute. For a wholly immutable object, the tools are a frozen dataclass (lesson 5), a NamedTuple, or a custom __setattr__ that raises after __init__ (Module 16). Immutable value objects are the easy ones to reason about, hash and share, and are what to reach for when a class represents a value (a point, a money amount, a date) rather than an entity with a life cycle (an account, a connection).
__slots__
By default every instance carries a __dict__. __slots__ declares the attribute names up front, and the instance stores them in fixed slots instead:
class Point:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x, self.y = x, y
p = Point(1, 2)
p.z = 3 # AttributeError: 'Point' object has no attribute 'z'
Two effects: memory per instance drops (roughly by half for small objects, which matters for millions of points), and attribute typos become errors instead of silently creating new attributes. The cost: no dynamic attributes, and subclasses must declare their own __slots__ or they get a __dict__ back. Use it for small, numerous, fixed-shape objects; @dataclass(slots=True) writes it for you.
What encapsulation is for
The point is not secrecy but invariants: a class promises that radius is never negative, that balance equals the sum of its transactions, that _index always matches _items. Every write path that could break the promise — the constructor, a setter, a method — is where the check goes, and readers of the class can then assume the invariant everywhere else. An attribute that can be set to anything by anyone has no invariant to assume. Underscores mark the state that only the class's own methods should touch; properties guard the state that callers may set.
Pitfalls
- Java-style
get_x/set_xmethods. - A property that does expensive work or has side effects.
__namefor privacy; it is for subclass collision avoidance.- Assigning to
self.radiusin__init__before the property exists in a way that bypasses validation (it does go through the setter; but assigning toself._radiusdirectly would not). cached_propertyon data that changes.- A typo in an attribute name silently creating a new attribute —
__slots__catches it.
Key takeaways
_namemarks internal state;__nameis compiler-mangled to_Class__namefor subclass safety; nothing is truly private.- Start with plain attributes;
@propertyand@x.setteradd validation, computation or read-only-ness later without changing callers. - Properties are cheap and pure; expensive work is a method;
cached_propertyfor expensive, stable values. - Value objects should be immutable: frozen dataclass or
NamedTuple. __slots__fixes the attribute set, saves memory and catches typos.
Common questions
Does Python have private variables?
No. A single leading underscore, _balance, marks an attribute as internal by convention, and nothing stops a caller reading it. A double leading underscore, __balance, is name-mangled to _ClassName__balance, which avoids clashes with a subclass's attribute of the same name but is not privacy.
Should I write getters and setters in Python?
Not up front. Start with a public attribute; if you later need validation or a computed value, turn it into a @property and every caller's obj.x keeps working. Methods such as get_x and set_x add ceremony without adding control.
What is __slots__ in Python?
__slots__ declares an instance's attribute names up front, so they are stored in fixed slots instead of a per-instance __dict__. Instances use less memory — roughly half for small objects — and assigning a misspelt attribute raises AttributeError. Subclasses must declare their own __slots__, and @dataclass(slots=True) generates it for you.
What is cached_property in Python?
functools.cached_property computes a value on first access and stores it in the instance's dict, so later reads cost nothing. Use it for expensive values that never change after construction; if the underlying data can change, the cached value goes stale, and del obj.attr clears it.
How do I make a read-only attribute in Python?
Define a @property with no setter: reading works and assigning raises AttributeError. For a wholly immutable object, use a frozen dataclass or a NamedTuple, which suits classes that represent values such as a point or an amount of money.
Exercises
Temperature with properties
Implement Temperature storing _celsius. celsius is a property whose setter rejects values below -273.15 with ValueError; fahrenheit is a computed property with a setter that converts and stores through celsius (so validation applies to both). Commands: c value sets Celsius, f value sets Fahrenheit, show prints both with two decimals; an invalid value prints invalid and leaves the temperature unchanged.
Input: commands (the temperature starts at 0 °C). Output: one line per show and per rejected value.
c 100
show
f 32
show
c -300
show
prints
100.00C 212.00F
0.00C 32.00F
invalid
0.00C 32.00FA slotted point
Define Point with __slots__ = ("x", "y"). Process commands: set name value assigns an attribute (an unknown name prints AttributeError), get name prints the value, and dict prints whether the instance has a __dict__ (hasattr(p, "__dict__")).
Input: commands (the point starts at 0 0). Output: one line per get, dict and failed set.
set x 5
set z 1
get x
dict
prints
AttributeError
5
FalseIn this module: Classes and objects
- Defining classes — __init__, self, attributes and methods
- Dunder methods — making a class behave like a built-in
- Properties and encapsulation — the underscore, @property and __slots__ (this lesson)
- Class methods and static methods
- Dataclasses — classes that are mostly data
- Designing a class — invariants, interfaces and a worked example
- Checkpoint — Classes and objects
← Dunder methods — making a class behave like a built-in · Class methods and static methods →