Python Encapsulation: Protected, Private, and Why the Convention Matters
Encapsulation is often introduced as “hiding data”. That description is partly right, but the more accurate framing is: encapsulation bundles data with the code that manages it, and controls how that data is accessed and changed. The goal is not secrecy — it is predictability. When an object controls its own state, you can trust that the state is always consistent.
Python’s Approach: Convention Over Enforcement
Languages like Java and C++ use private, protected, and public keywords that the compiler enforces. Python takes a different philosophy: we are all adults here. Python signals intent through naming conventions. These conventions are respected community-wide, but they are not locked doors.
| Naming | Convention | What It Means |
|---|---|---|
name | Public | Freely accessible from anywhere |
_name | Protected | By convention, internal to the class and its subclasses |
__name | Private (name-mangled) | Strong signal: do not touch this |
Public Attributes
No prefix, no restrictions. Most attributes in Python classes are public by default.
class Circle: def __init__(self, radius): self.radius = radius # public
def area(self): import math return math.pi * self.radius ** 2
c = Circle(5)c.radius = 10 # perfectly fine — it's publicprint(c.area()) # 314.159...Use public attributes when you have no reason to restrict access — which is most of the time.
Protected Attributes (_single_underscore)
A single underscore prefix is a convention meaning “this is internal, handle with care”. Python does not enforce it at all — you can still access _attr from outside the class. But seeing _attr in code is a signal to stop and think before touching it.
class Config: def __init__(self): self._settings = {} # intended for internal use
def set(self, key, value): self._settings[key] = value
def get(self, key, default=None): return self._settings.get(key, default)
cfg = Config()cfg.set("timeout", 30)print(cfg.get("timeout")) # 30
# Technically accessible, but you're on your ownprint(cfg._settings) # {'timeout': 30}Protected attributes are commonly used in base classes to signal that subclasses may access the attribute but external code should not.
Private Attributes (__double_underscore)
Two leading underscores trigger name mangling: Python renames __attr to _ClassName__attr internally. This makes accidental access from outside much less likely, and prevents subclass name collisions.
class Vault: def __init__(self, secret_code): self.__code = secret_code # stored as _Vault__code
def verify(self, attempt): return attempt == self.__code
v = Vault("xK9#m2")print(v.verify("wrong")) # Falseprint(v.verify("xK9#m2")) # True
# Direct access fails# print(v.__code) # AttributeError
# Name-mangled access works, but this is bad practiceprint(v._Vault__code) # xK9#m2 — please don't do thisName mangling is not encryption. A determined developer can still access the attribute. The convention communicates “this is an implementation detail that may change without notice.”
Properties: The Pythonic Alternative to Getters and Setters
In some languages, you write getName() and setName() methods for every attribute. Python provides a cleaner mechanism through the @property decorator.
class Temperature: def __init__(self, celsius): self._celsius = celsius
@property def celsius(self): """Getter — accessed like an attribute.""" return self._celsius
@celsius.setter def celsius(self, value): """Setter — runs when you assign to the attribute.""" if value < -273.15: raise ValueError(f"{value}°C is below absolute zero") self._celsius = value
@property def fahrenheit(self): """Read-only computed property.""" return self._celsius * 9 / 5 + 32
t = Temperature(25)print(t.celsius) # 25 — calls the getterprint(t.fahrenheit) # 77.0
t.celsius = 100 # calls the setterprint(t.fahrenheit) # 212.0
t.celsius = -300 # raises ValueErrorFrom outside the class, t.celsius looks like a plain attribute. Inside, it runs through the getter and setter logic. This means you can start with a plain public attribute and later add a property with validation without changing the public interface. Callers do not need to update their code.
A read-only property (getter with no setter) raises AttributeError if you try to assign to it:
# t.fahrenheit = 100 # AttributeError — no setter definedWhen to Use Each Level
Public (name): For anything you are comfortable with any caller reading or modifying. Default choice.
Protected (_name): When you want to signal “this is an internal implementation detail”. Useful in base classes where subclasses need access but external code should not depend on it.
Private (__name): When you genuinely do not want subclasses or outside code to accidentally conflict with this name. Common in deep frameworks where name clashes across inheritance chains would be confusing.
Property: When a plain attribute later needs validation, computation, or side effects. Start with a plain attribute and add a property when you need it — this is cheaper than writing getters/setters for everything upfront.
A Practical Encapsulation Example
class Inventory: def __init__(self): self._stock = {} # protected — subclasses may use it
def add_item(self, product, quantity): if quantity < 0: raise ValueError("Quantity cannot be negative") self._stock[product] = self._stock.get(product, 0) + quantity
def remove_item(self, product, quantity): current = self._stock.get(product, 0) if quantity > current: raise ValueError(f"Cannot remove {quantity}; only {current} in stock") self._stock[product] = current - quantity
def count(self, product): return self._stock.get(product, 0)
@property def total_items(self): return sum(self._stock.values())
inv = Inventory()inv.add_item("widget", 50)inv.add_item("gadget", 20)inv.remove_item("widget", 10)
print(inv.count("widget")) # 40print(inv.total_items) # 60The _stock dictionary is protected — callers use add_item, remove_item, and count instead of manipulating it directly. This means you could change the internal storage (say, from a dict to a database) without breaking any code that uses Inventory.
Common Mistakes
Over-privatising everything. Using __attr everywhere makes inheritance painful and does not actually protect data from a determined caller. Use it sparingly.
Forgetting to use self._attr in methods. If you define __attr in __init__ and then try to access attr (without the prefix) in another method, you get an AttributeError.
Writing getters and setters for everything upfront. Start with plain public attributes. Add properties when you actually need control — not preemptively.