Encapsulation in Python | Python Course | Dataplexa

Encapsulation in Python

Encapsulation is one of the four pillars of object-oriented programming. It means bundling data and the methods that operate on that data inside a class — and controlling how that data is accessed and modified from outside. Done well, encapsulation protects your objects from invalid state, hides complexity, and lets you change internal implementation without breaking any code that uses your class.

Python takes a pragmatic approach: conventions and tools rather than hard enforcement. This lesson covers naming conventions, getters and setters, the @property decorator, computed properties, and the property deleter.

Why Encapsulation Matters

Without encapsulation, any code anywhere can reach into your object and set data to anything — including values that break your logic.

class BankAccount:
    def __init__(self, owner, balance):
        self.owner   = owner
        self.balance = balance

acc = BankAccount("Alice", 500.00)

# Nothing prevents this — even though it makes no sense
acc.balance = -99999
print(acc.balance)   # -99999  — invalid, but allowed

# Multiple accounts — any external code can corrupt any of them
acc.owner   = ""     # empty owner — also allowed
acc.balance = "hello"  # wrong type — allowed, will crash later
-99999

Encapsulation solves this by making the object responsible for its own state — external code can only interact through a controlled interface.

1. Naming Conventions — Public, Protected, Private

Python signals intended visibility through naming conventions. There are no hard access keywords — the conventions are respected by developers and partially enforced by the language.

class Employee:
    company = "Dataplexa"          # public class attribute

    def __init__(self, name, salary, ssn):
        self.name    = name        # public   — accessible anywhere
        self._salary = salary      # protected — internal use intended
        self.__ssn   = ssn         # private   — name-mangled by Python

    def get_info(self):
        return f"{self.name} | Salary: ${self._salary:,.2f}"

    def verify_identity(self, ssn):
        return self.__ssn == ssn   # private used internally

emp = Employee("Alice", 85000, "123-45-6789")

print(emp.name)           # Alice        — fine
print(emp._salary)        # 85000        — works, but discouraged
# print(emp.__ssn)        # AttributeError — name mangled

# Name-mangled form — accessible but signals "you shouldn't"
print(emp._Employee__ssn)         # 123-45-6789
print(emp.get_info())
print(emp.verify_identity("123-45-6789"))   # True
print(emp.verify_identity("000-00-0000"))   # False
Alice 85000 123-45-6789 Alice | Salary: $85,000.00 True False
  • Single underscore _ is a convention — Python does not restrict access at all.
  • Double underscore __ triggers name mangling: __ssn inside Employee becomes _Employee__ssn — accidental external access fails with AttributeError.
  • Python's philosophy: "We're all consenting adults here" — trust conventions rather than fighting them.
  • Use __ sparingly — mainly to prevent subclass name collisions, not as a routine habit.

2. Getters and Setters

The traditional approach: explicit methods for controlled read and write access. Python supports this, though @property is generally preferred.

class BankAccount:
    def __init__(self, owner, balance):
        self.owner    = owner
        self._balance = 0.0
        self.set_balance(balance)   # runs validation immediately

    def get_balance(self):
        return self._balance

    def set_balance(self, amount):
        if not isinstance(amount, (int, float)):
            raise TypeError(f"Balance must be a number, got {type(amount).__name__}.")
        if amount < 0:
            raise ValueError(f"Balance cannot be negative, got {amount}.")
        self._balance = float(amount)

    def deposit(self, amount):
        self.set_balance(self._balance + amount)
        print(f"Deposited ${amount:.2f}. Balance: ${self._balance:.2f}")

acc = BankAccount("Alice", 500.00)
print(acc.get_balance())    # 500.0
acc.deposit(200)
acc.set_balance(750)
print(acc.get_balance())    # 750.0

try:
    acc.set_balance(-100)
except ValueError as e:
    print("Error:", e)
500.0 Deposited $200.00. Balance: $700.00 750.0 Error: Balance cannot be negative, got -100.

3. The @property Decorator — Pythonic Encapsulation

@property lets you expose an attribute with clean syntax while secretly running a method behind the scenes. The caller uses acc.balance — exactly like a plain attribute — and never knows validation is happening.

Key benefit: you can start with a plain public attribute, then later add validation by converting it to a property — without changing any code that uses it. The interface stays identical.

class BankAccount:
    def __init__(self, owner, balance):
        self.owner   = owner
        self.balance = balance      # calls the setter immediately

    @property
    def balance(self):
        """Current account balance."""
        return self._balance

    @balance.setter
    def balance(self, amount):
        if not isinstance(amount, (int, float)):
            raise TypeError(f"Balance must be a number.")
        if amount < 0:
            raise ValueError(f"Balance cannot be negative, got {amount}.")
        self._balance = float(amount)

    @balance.deleter
    def balance(self):
        print("Resetting balance to zero.")
        self._balance = 0.0

    def deposit(self, amount):
        self.balance += amount      # setter validates automatically
        print(f"Balance: ${self.balance:.2f}")

acc = BankAccount("Alice", 500.00)
print(acc.balance)   # 500.0 — clean attribute read

acc.deposit(250)     # Balance: $750.00

del acc.balance      # calls the deleter
print(acc.balance)   # 0.0

try:
    acc.balance = -50
except ValueError as e:
    print("Error:", e)
500.0 Balance: $750.00 Resetting balance to zero. 0.0 Error: Balance cannot be negative, got -50.
  • The caller uses acc.balance — identical to a plain attribute — no method calls needed.
  • Setting self.balance = balance in __init__ goes through the setter — validation runs at construction.
  • Store data in self._balance, not self.balance — assigning to self.balance inside the setter would call the setter recursively and crash.
  • Read-only property: define only the getter — any assignment raises AttributeError automatically.
  • The @name.deleter decorator defines what happens when del obj.name is called.

4. Computed Properties

A property does not have to read from stored data. It can compute a value on the fly from other attributes — always in sync, no manual update required.

class Order:
    TAX_RATE = 0.08   # 8% tax — class attribute

    def __init__(self, items):
        self.items = items   # list of (name, price, qty)

    @property
    def subtotal(self):
        return sum(price * qty for _, price, qty in self.items)

    @property
    def tax(self):
        return round(self.subtotal * self.TAX_RATE, 2)

    @property
    def total(self):
        return round(self.subtotal + self.tax, 2)

    @property
    def item_count(self):
        return sum(qty for _, _, qty in self.items)

order = Order([
    ("notebook",  4.99, 3),
    ("pen",       1.50, 10),
    ("desk",     89.99, 1),
])

print(f"Items    : {order.item_count}")
print(f"Subtotal : ${order.subtotal:.2f}")
print(f"Tax      : ${order.tax:.2f}")
print(f"Total    : ${order.total:.2f}")

# Add an item — all computed properties update automatically
order.items.append(("lamp", 24.99, 2))
print(f"New total: ${order.total:.2f}")
Items : 23 Subtotal : $119.96 Tax : $9.60 Total : $129.56 New total: $184.54
  • Computed properties stay in sync automatically — update the underlying data and every derived value reflects the change instantly.
  • No setter needed — naturally read-only since they are always derived.
  • Cleaner than storing subtotal/tax/total separately and keeping them manually synchronised.

5. Complete Example — User Class

class User:
    _count = 0

    def __init__(self, username, email, age):
        self.username = username   # goes through setter
        self.email    = email
        self.age      = age
        User._count  += 1

    @property
    def username(self):
        return self._username

    @username.setter
    def username(self, value):
        if not value or not isinstance(value, str):
            raise ValueError("Username must be a non-empty string.")
        self._username = value.strip().lower()

    @property
    def email(self):
        return self._email

    @email.setter
    def email(self, value):
        if "@" not in str(value) or "." not in str(value):
            raise ValueError(f"Invalid email: {value!r}")
        self._email = value.lower().strip()

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if not isinstance(value, int) or not (0 < value < 130):
            raise ValueError("Age must be an integer between 1 and 129.")
        self._age = value

    @classmethod
    def count(cls):
        return cls._count

    def __repr__(self):
        return f"User({self.username!r}, {self.email!r}, age={self.age})"

u1 = User("  Alice  ", "Alice@Example.COM", 30)
u2 = User("Bob", "bob@test.org", 25)

print(u1)
print(u2)
print(f"Total users: {User.count()}")

try:
    u1.age = 200
except ValueError as e:
    print("Error:", e)

# Can update via setter — validation still runs
u1.username = "  ALICE_2  "
print(u1.username)   # alice_2 — normalised
User('alice', 'alice@example.com', age=30) User('bob', 'bob@test.org', age=25) Total users: 2 Error: Age must be an integer between 1 and 129. alice_2

Quick Reference Table

Tool / ConventionSyntaxPurpose
Public attributeself.nameAccessible from anywhere
Protected attributeself._nameInternal use — accessible but discouraged externally
Private attributeself.__nameName-mangled — prevents accidental external access
Getter / Setterget_x() / set_x()Explicit controlled access — verbose but clear
@property@property def name(self):Read access with optional computation
@name.setter@name.setter def name(self, v):Write access with validation
@name.deleter@name.deleter def name(self):Custom behaviour on del obj.name

Practice

What does a single underscore prefix on an attribute signal in Python?



What does Python do internally when you define self.__ssn inside class Employee?



Why is @property preferred over explicit getters and setters in Python?



If you define a property getter but no setter, what happens when code tries to assign to it?



Why does a property setter use self._balance for storage instead of self.balance?



Which decorator defines what happens when del obj.name is called on a property?



Quick Quiz

What is the main purpose of encapsulation?





What is the mangled name of self.__balance inside class BankAccount?





Which correctly defines a read-only property called area?





Why can you safely refactor a plain attribute into a @property without breaking existing callers?





A computed property like total derived from subtotal and tax — does it need a setter?





What decorator handles del obj.name for a property?





NEXT UP
Abstraction in Python
Hiding complexity behind clean interfaces using abstract classes and the abc module — the fourth pillar of OOP.