W3docs

Python Encapsulation

Learn Python encapsulation: public, protected, and private members, name mangling, and clean getters/setters with @property.

Encapsulation is one of the four pillars of object-oriented programming. It means bundling an object's data (attributes) and the methods that operate on that data into a single unit, while controlling which parts of the object the outside world can access or modify.

Done well, encapsulation keeps an object's internal state consistent, hides implementation details so you can change them later without breaking callers, and makes your classes safer to use.

This chapter covers:

  • What encapsulation is and why it matters
  • Public, protected, and private members — and the naming conventions Python uses
  • Name mangling — how __double_underscore attributes really work
  • Getters and setters with @property
  • A real-world example tying it all together

Before reading, make sure you are comfortable with Python classes and objects. For access control via computed attributes, see the closely related chapter on @property.

Why Encapsulation Matters

Consider a bank account. Internally it tracks a balance. If that balance were a plain attribute anyone could set, nothing stops a bug (or a bad actor) from doing:

account.balance = -9999999

Encapsulation solves this by hiding the balance behind a controlled interface. Code outside the class can only deposit or withdraw through methods that enforce the business rules. The internal storage is an implementation detail — callers never touch it directly.

The three benefits this gives you are:

  1. Data integrity — validation logic in one place, enforced every time.
  2. Flexibility — you can change the internal representation (e.g., store balances in cents instead of dollars) without touching any calling code.
  3. Reduced coupling — callers depend only on the public interface, not on how the class works internally.

Access Levels: Public, Protected, and Private

Python does not have access modifiers like private or public keywords. Instead, it uses a naming convention to signal intent:

PrefixExampleAccess levelMeaning
No prefixbalancePublicIntended for use by anyone
Single underscore __balanceProtectedFor internal use and subclasses; usable externally but discouraged
Double underscore ____pinPrivateFor this class only; Python actively renames it to prevent easy access

These are conventions and mechanisms, not hard rules enforced by a compiler. Python trusts developers to respect the signal.

Public members

Public attributes and methods form the class's official interface — the part you intend callers to use:

class BankAccount:
    account_type = 'savings'   # public class attribute

    def __init__(self, owner, balance):
        self.owner = owner     # public instance attribute

    def deposit(self, amount):
        pass                   # public method

No special naming is needed. Any code can read or write a public member freely.

Protected members (single underscore _)

A single leading underscore is a signal that says "this is an internal detail — please don't rely on it from outside the class." Python does not enforce this; it is purely a convention:

class BankAccount:
    def __init__(self, owner, balance):
        self.owner = owner
        self._balance = balance   # protected — internal, but subclasses may need it

    def _validate_amount(self, amount):   # protected helper
        return isinstance(amount, (int, float)) and amount > 0

_balance is still accessible as account._balance from outside, but the underscore warns other developers (and linters) that they are breaking the intended contract.

A common use: a base class stores data in a _ attribute so subclasses can read it, while keeping it hidden from unrelated code.

Private members (double underscore __)

A double leading underscore triggers name mangling — Python renames the attribute internally to _ClassName__attribute. This makes accidental access from outside much harder:

class BankAccount:
    def __init__(self, owner, balance):
        self.owner = owner
        self._balance = balance
        self.__pin = 1234       # private — not meant to be touched at all

    def verify_pin(self, pin):
        return pin == self.__pin

From outside the class:

acc = BankAccount('Alice', 1000)
print(acc.owner)      # Alice   — public, fine
print(acc._balance)   # 1000    — protected, works but frowned upon
print(acc.__pin)      # AttributeError: 'BankAccount' object has no attribute '__pin'

The attribute still exists, but under a different name. See the next section for how to find it.

Name Mangling

When Python sees self.__name inside a class definition, it internally rewrites it to self._ClassName__name. This is name mangling. The purpose is to avoid accidental clashes in subclasses — not to provide true security.

class Counter:
    def __init__(self):
        self.__count = 0

    def increment(self):
        self.__count += 1

    def value(self):
        return self.__count

c = Counter()
c.increment()
c.increment()
print(c.value())          # 2

# Direct access fails:
# print(c.__count)        # AttributeError

# But mangled name still works if you know it:
print(c._Counter__count)  # 2

You can inspect all attributes with vars() or dir() to discover the mangled name:

print(list(vars(c)))
# ['_Counter__count']

Name mangling and inheritance

Name mangling is especially useful in inheritance. Without it, a subclass could accidentally overwrite a private attribute of its parent by using the same name. With mangling, each class gets its own namespace:

class Base:
    def __init__(self):
        self.__secret = 'base'

    def reveal(self):
        return self.__secret    # accesses _Base__secret

class Child(Base):
    def __init__(self):
        super().__init__()
        self.__secret = 'child'  # stored as _Child__secret, not the same thing

    def reveal_child(self):
        return self.__secret     # accesses _Child__secret

c = Child()
print(c.reveal())        # base   — Base.reveal() reads _Base__secret
print(c.reveal_child())  # child  — Child.reveal_child() reads _Child__secret

Both attributes coexist without collision, which would not be possible without name mangling.

Getters and Setters with @property

In many languages you write explicit get_x() and set_x() methods. Python provides a cleaner approach: the @property decorator lets you expose a method as if it were a plain attribute, so the calling code stays readable while you keep full control over reading and writing.

Basic getter

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius

    @property
    def celsius(self):
        return self._celsius

Callers read t.celsius, not t.celsius(). The @property makes the method call invisible:

t = Temperature(25)
print(t.celsius)   # 25  — no parentheses needed

Adding a setter with validation

Pair @property with a .setter to validate values before storing them:

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError('Temperature below absolute zero')
        self._celsius = value

    @property
    def fahrenheit(self):
        return self._celsius * 9 / 5 + 32
t = Temperature(25)
print(t.celsius)      # 25
print(t.fahrenheit)   # 77.0

t.celsius = 100
print(t.fahrenheit)   # 212.0

t.celsius = -300      # ValueError: Temperature below absolute zero

fahrenheit is a read-only computed property — no setter is defined, so Python raises an AttributeError if you try to assign to it.

Why prefer @property over plain getters/setters?

You can start with a plain public attribute and upgrade it to a property later without changing any calling code:

# v1 — plain attribute
class Circle:
    def __init__(self, radius):
        self.radius = radius

# v2 — property with validation, same public interface
class Circle:
    def __init__(self, radius):
        self.radius = radius   # still works from the caller's point of view

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        if value < 0:
            raise ValueError('Radius cannot be negative')
        self._radius = value

Callers that wrote c.radius = 5 continue to work unchanged. Only the behaviour changes — you now validate the value.

For a full reference on @property including deleters, see Python @property.

A Complete Example: User Account

The following example shows all three access levels working together in a realistic class:

class UserAccount:
    def __init__(self, username, password):
        self.username = username           # public
        self._login_attempts = 0           # protected — subclasses may need this
        self.__password_hash = self.__hash(password)  # private

    def __hash(self, password):
        """Private helper — implementation detail, may change."""
        return hash(password)

    def check_password(self, password):
        """Public method — part of the official interface."""
        return self.__hash(password) == self.__password_hash

    def login(self, password):
        if self._login_attempts >= 3:
            return 'Account locked'
        if self.check_password(password):
            self._login_attempts = 0
            return 'Login successful'
        self._login_attempts += 1
        return f'Wrong password ({self._login_attempts}/3)'


user = UserAccount('alice', 'secret123')
print(user.login('bad'))         # Wrong password (1/3)
print(user.login('bad'))         # Wrong password (2/3)
print(user.login('bad'))         # Wrong password (3/3)
print(user.login('secret123'))   # Account locked

Notice:

  • username is public — it is fine for anyone to read.
  • _login_attempts is protected — a ThrottledAccount subclass could read it to implement smarter logic.
  • __password_hash and __hash() are private — the password storage strategy is purely internal. Callers have no reason to see it, and if you later switch to bcrypt you only change those two things.

Encapsulation vs. Other OOP Pillars

Encapsulation is one of four OOP principles:

PrincipleOne-line definition
EncapsulationBundle data + methods; hide internal details
InheritanceLet a class reuse and extend another class
PolymorphismLet different types respond to the same method call
AbstractionExpose a simplified interface; hide complexity

See Python inheritance, Python polymorphism, and Python abstract classes for the other pillars.

Quick Reference

ConventionWhat it signalsEnforced by Python?
namePublic — use freelyNo (always accessible)
_nameProtected — internal useNo (accessible but conventionally off-limits)
__namePrivate — this class onlyPartially — name is mangled to _ClassName__name
@propertyControlled read accessYes — getter/setter/deleter hooks
@name.setterControlled write access with validationYes

Practice

Practice
What does a single leading underscore (e.g. `_balance`) signal in Python?
What does a single leading underscore (e.g. `_balance`) signal in Python?
Was this page helpful?