python encapsulation is about bundling an object's data together with the methods that operate on it, while controlling how that data can be accessed and changed from outside the class. This article covers python private protected attributes, the @property decorator that makes controlled access feel natural, and where Python's approach genuinely differs from stricter, compiler-enforced languages.
What Is Encapsulation?
Encapsulation means bundling data and the methods that operate on it into a single class, while restricting direct, uncontrolled access to some of that internal data from outside.
Reframing the goal
It's easy to think of encapsulation as simply "hiding" data, but that framing undersells the actual point. The real goal is providing a clear, consistent interface to an object's behavior, while protecting its internal state from being changed into something invalid or inconsistent. It's less about secrecy, and more about control — ensuring that whatever changes do happen to an object's data go through logic designed to keep that data valid.
A real-world framing
A bank account's balance shouldn't be directly settable to just any value from outside the class — account.balance = -500 shouldn't be something arbitrary code elsewhere in your program can simply do without any check at all. Encapsulation is what lets a class enforce rules like "balance can never go negative" consistently, no matter where in your program the account is being used.
Public, Protected, and Private Attributes
Python expresses different levels of intended access through naming conventions, rather than through strict, enforced access modifiers like some other languages use.
Public attributes (no prefix)
By default, every attribute is public — freely accessible and modifiable from anywhere:
class Account:
def __init__(self, balance):
self.balance = balance # public
account = Account(100)
account.balance = 500 # freely allowed, no restriction at allProtected attributes (single underscore)
A single leading underscore, _name, is a naming convention signaling "this is intended for internal use, please don't touch it from outside the class" — but Python doesn't actually enforce this in any way. It's a polite request, not a technical restriction:
class Account:
def __init__(self, balance):
self._balance = balance # protected, by convention
account = Account(100)
print(account._balance) # 100 — still fully accessible, Python doesn't stop you
account._balance = 500 # still fully modifiable tooNothing about the single underscore prevents access — it's purely a signal to other developers (and to you, later) that this attribute isn't part of the class's intended public interface, even though Python itself won't stop anyone from reaching in and touching it directly.
Private attributes (double underscore) and name mangling
A double leading underscore, __name, triggers something called name mangling — Python internally renames the attribute to _ClassName__name, making accidental access from outside the class genuinely harder, though still not truly impossible:
class Account:
def __init__(self, balance):
self.__balance = balance # private, triggers name mangling
account = Account(100)
print(account.__balance)
# AttributeError: 'Account' object has no attribute '__balance'
print(account._Account__balance) # 100 — still technically accessible, if you know the mangled nameThe AttributeError on the first attempt isn't because __balance genuinely doesn't exist — it's because Python silently renamed it to _Account__balance behind the scenes, so account.__balance (which Python interprets as looking for exactly that literal name) simply doesn't find anything by that name. If you know the mangling pattern, you can still reach the attribute directly — Python relies fundamentally on convention and mutual trust between developers, rather than truly compiler-enforced restrictions the way Java's private keyword or C++'s access specifiers work.
Getter and Setter Methods
The traditional pattern
A common approach, especially familiar to developers coming from Java or C++, is writing explicit get_x()/set_x() methods to control read and write access to an attribute:
class Person:
def __init__(self, age):
self.__age = age
def get_age(self):
return self.__age
def set_age(self, value):
if value < 0:
raise ValueError("Age cannot be negative")
self.__age = value
person = Person(30)
print(person.get_age()) # 30
person.set_age(-5)
# ValueError: Age cannot be negativeAdding validation in the setter
The genuine value here is that set_age() can validate incoming data before it's actually assigned — rejecting a negative age, for instance, rather than silently allowing an object to end up in an invalid state.
An honest critique
This explicit getter/setter style works, and if you're familiar with other object-oriented languages, it'll look immediately recognizable. But it's widely considered less Pythonic, and often unnecessarily verbose for what it accomplishes — every single attribute access turns into an explicit method call (person.get_age() instead of the more natural person.age), adding real boilerplate for a benefit that Python has a considerably more elegant built-in tool for, covered next.
The Pythonic Way: @property
How @property works
@property lets a method be accessed exactly like a plain attribute — no parentheses required — while still running real getter (and optionally setter) logic behind the scenes, completely transparently to whoever's using the class:
class Person:
def __init__(self, age):
self.__age = age
@property
def age(self):
return self.__age
person = Person(30)
print(person.age) # 30 — accessed like a plain attribute, but this is actually a method callperson.age looks exactly like accessing a plain public attribute from the outside, but it's genuinely running the age() method behind the scenes every single time — the caller doesn't need to know or care about that distinction.
Adding a setter with validation
@x.setter lets you add write access, with validation logic, while keeping that same natural attribute-style syntax:
class Person:
def __init__(self, age):
self.__age = age
@property
def age(self):
return self.__age
@age.setter
def age(self, value):
if value < 0:
raise ValueError("Age cannot be negative")
self.__age = value
person = Person(30)
person.age = 31 # runs the setter's validation logic transparently
print(person.age) # 31
person.age = -5
# ValueError: Age cannot be negativeperson.age = 31 looks exactly like assigning to a plain attribute — but it's actually calling the age setter method, which validates the new value before actually storing it. This gives you all the same control as the explicit get_age()/set_age() pattern from Section 3, with syntax that reads far more naturally.
@x.deleter for cleanup logic
You can similarly define cleanup logic that runs when del is used on the property:
class Person:
def __init__(self, age):
self.__age = age
@property
def age(self):
return self.__age
@age.deleter
def age(self):
print("Deleting age attribute")
del self.__age
person = Person(30)
del person.age
# Deleting age attributeRead-only properties
Defining only a getter — no corresponding @x.setter — creates a read-only property, useful for exposing a computed value that genuinely shouldn't be directly reassignable from outside the class:
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def area(self):
return 3.14159 * self.radius ** 2
circle = Circle(5)
print(circle.area) # 78.53975 — computed on the fly, each time it's accessed
circle.area = 100
# AttributeError: property 'area' of 'Circle' object has no setterarea is computed dynamically from radius every time it's accessed — there's no sensible way to directly "set" it, since it's derived entirely from other data, so no setter is defined at all, and Python correctly rejects any attempt to assign to it directly.
Best Practices and Common Pitfalls
Start with plain public attributes
A genuinely important piece of practical guidance: start with plain, ordinary public attributes. Only reach for @property once validation, computed values, or genuinely controlled access actually becomes necessary. Wrapping every single attribute in a property reflexively, from the very start of writing a class, adds real complexity for no actual benefit in the common case where a plain attribute would have worked perfectly well.
# Unnecessary — no validation, no computation, just needless boilerplate
class Point:
def __init__(self, x, y):
self.__x = x
self.__y = y
@property
def x(self):
return self.__x
@property
def y(self):
return self.__y
# Simpler, and equally correct, since there's no actual validation or computation happening
class Point:
def __init__(self, x, y):
self.x = x
self.y = yIf x and y never need validation or computed derivation, the plain public version is genuinely the better choice — not a "lesser" or incomplete version of the property-based one. A genuinely nice feature of @property is that you can start with plain public attributes, and later convert one to a property without breaking any existing code that accesses it — since the syntax for accessing a plain attribute and a property looks identical from the outside.
Common pitfall: over-encapsulating everything
A related, common mistake, especially among developers newly excited about encapsulation as a concept: wrapping every single attribute in private naming and boilerplate getters/setters (or properties) reflexively, regardless of whether any actual validation or control is needed. This adds genuine complexity and verbosity without a corresponding benefit, and it works against Python's general preference for simple, direct code where simplicity is actually sufficient.
A quick summary mindset
Public (
name) — for freely accessible data with no special rules or validation needed. This should be your default starting point for most attributes.Protected (
_name) — for internal state that subclasses may still reasonably need to access or extend, signaling "treat this carefully" without fully blocking it.Private +
@property(__namewrapped in a property) — reserved specifically for values that genuinely need validation on write, computation on read, or a deliberately controlled, curated public interface distinct from the object's raw internal storage.
Encapsulation done well in Python isn't about maximizing how much you lock down — it's about applying exactly the right amount of control to exactly the attributes that genuinely need it, while leaving everything else simple and directly accessible.