Encapsulation in Python

By the Syncfusion DevTutorials team · Last updated

Keeping data inside the class: data hiding, getters and setters, validation, and why Python uses @property instead.

Think about a bank account.

Your balance is a number the bank holds. You cannot reach in and set it to a million. You deposit, you withdraw, and the bank checks each one: enough money, a sensible amount, a real account.

The number is protected, and every way of changing it goes through a check.

Encapsulation is that idea in code: keep the data behind the class, and let it be changed only through methods that can say no.

A value that protects itself

class Student:
    def __init__(self, name):
        self.name = name
        self._marks = 0

    def set_marks(self, value):
        if not 0 <= value <= 100:
            raise ValueError("marks must be between 0 and 100")
        self._marks = value

    def get_marks(self):
        return self._marks

asha = Student("Asha")
asha.set_marks(78)

print(asha.get_marks())

try:
    asha.set_marks(150)
except ValueError as error:
    print("Refused:", error)

Encapsulation means keeping data inside the class and controlling every way it can be changed.

What You Will Learn

In this lesson, you will learn:

  • What encapsulation is, and what goes wrong without it
  • How Python marks data as internal, and what that does and does not stop
  • How to write getters and setters, and how to check values on the way in
  • Why Python prefers @property to the getter and setter methods other languages use

By the end of this lesson, you will be able to protect class data, write methods that check and validate changes, use properties to control access, and understand why Python's approach to encapsulation is better than the getter-setter methods found in other languages.

Encapsulation

What Is Encapsulation?

Encapsulation means keeping data inside a class and controlling how it is changed. It has two parts:

  • The data and the methods that work on it live together in one class
  • The data is only reached through those methods, not directly

The second part is what matters: by forcing all changes through methods, you can check each one. This prevents impossible values from ever entering an object.

Data Hiding

When you write a class without encapsulation, anything can put anything into an object. Watch what happens:

Nothing stops a nonsense value

class Student:
    def __init__(self, name, marks):
        self.name = name
        self.marks = marks

asha = Student("Asha", 78)

asha.marks = 5000
asha.marks = -20
asha.marks = "absent"

print(asha.name, asha.marks)

Three impossible values went in without a word. The object is now broken, and the program will fail somewhere far away — in a function that tries to add up marks, or draw a chart, or compare a grade. The failure will not be at the line that caused the damage; it will be far away, making the bug hard to find.

This separation between the bad line and the failure is the real problem:

The failure happens somewhere else entirely

class Student:
    def __init__(self, name, marks):
        self.name = name
        self.marks = marks

asha = Student("Asha", 78)
asha.marks = "absent"

print("The bad assignment caused no error at all")

print("Average:", asha.marks / 1)

The bad assignment happened on line 7 and caused no error. Line 9 printed normally. The error appeared on line 11, four lines later, when the code tried to divide text by 1. By then, it is hard to trace the problem back to its source.

Benefits of Encapsulation

BenefitWhat it means
Values stay validevery change goes through a check
Errors arrive earlythe bad line fails, not some later one
Freedom to changethe inside can be rewritten without breaking callers
One place to lookall the rules about marks live in one class
A smaller surfacecallers see a few methods, not every attribute

Encapsulation Examples

Here is a class that protects itself. Every change goes through a method, so the balance can never go negative and the history can never skip a transaction:

A class that keeps itself consistent

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self._balance = balance
        self._history = []

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("deposit must be more than zero")
        self._balance += amount
        self._history.append(f"deposit {amount}")

    def withdraw(self, amount):
        if amount > self._balance:
            raise ValueError("not enough money")
        self._balance -= amount
        self._history.append(f"withdraw {amount}")

    def balance(self):
        return self._balance

    def statement(self):
        return list(self._history)

account = BankAccount("Asha", 100)
account.deposit(50)
account.withdraw(30)

print("Balance:", account.balance())
print("History:", account.statement())

try:
    account.withdraw(1000)
except ValueError as error:
    print("Refused:", error)

Notice that statement() returns list(self._history) — a copy of the history, not the history itself. If we handed back the real list, a caller could change it from outside, breaking the encapsulation.

Here is why that matters:

Why the copy matters

class BankAccount:
    def __init__(self):
        self._history = ["deposit 50"]

    def leaky_statement(self):
        return self._history

    def safe_statement(self):
        return list(self._history)

account = BankAccount()

account.leaky_statement().append("deposit 1000000")
print("After the leaky one:", account.safe_statement())

account.safe_statement().append("deposit 1000000")
print("After the safe one: ", account.safe_statement())

The first call reaches the real list and changes it. The second call gets a copy, changes the copy, and leaves the account alone. This shows why the copy is essential.

Getters and Setters

Getter Methods

A getter is a method that hands back a value. The simplest getters just return what is stored:

A getter

class Student:
    def __init__(self, name, marks):
        self._name = name
        self._marks = marks

    def get_name(self):
        return self._name

    def get_marks(self):
        return self._marks

asha = Student("Asha", 78)

print(asha.get_name())
print(asha.get_marks())

A getter can also work something out rather than just return a stored value:

A getter that calculates

class Student:
    def __init__(self, marks):
        self._marks = marks

    def get_average(self):
        return round(sum(self._marks) / len(self._marks), 1)

    def get_grade(self):
        average = self.get_average()
        return "A" if average >= 80 else "B" if average >= 60 else "C"

asha = Student([78, 85, 60])

print(asha.get_average())
print(asha.get_grade())

Setter Methods

A setter changes a value. More importantly, it is where you check the value before storing it:

A setter that checks

class Student:
    def __init__(self, name):
        self._name = name
        self._marks = 0

    def set_marks(self, value):
        if not isinstance(value, int):
            raise TypeError("marks must be a whole number")
        if not 0 <= value <= 100:
            raise ValueError("marks must be between 0 and 100")
        self._marks = value

    def get_marks(self):
        return self._marks

asha = Student("Asha")

asha.set_marks(78)
print(asha.get_marks())

for bad in [150, -20, "absent"]:
    try:
        asha.set_marks(bad)
    except (ValueError, TypeError) as error:
        print(f"{bad!r} refused: {error}")

print("Marks are still:", asha.get_marks())

Three bad values were refused with clear error messages. The object never held an impossible value, not even for an instant.

Data Validation

The key idea is that the checking lives in one place, so it cannot be forgotten. Every way of changing the data must go through the setter:

One rule, enforced everywhere

class Student:
    def __init__(self, name, marks=0):
        self._name = name
        self._marks = 0
        self.set_marks(marks)

    def set_marks(self, value):
        if not 0 <= value <= 100:
            raise ValueError(f"marks must be 0 to 100, got {value}")
        self._marks = value

    def raise_marks(self, points):
        self.set_marks(self._marks + points)

    def get_marks(self):
        return self._marks

asha = Student("Asha", 95)
print(asha.get_marks())

asha.raise_marks(3)
print(asha.get_marks())

try:
    asha.raise_marks(10)
except ValueError as error:
    print("Refused:", error)

Notice that __init__ calls self.set_marks(marks) instead of assigning directly. This means the check runs at creation. Similarly, raise_marks also goes through the setter. So there is only one place where marks can be set, and the rule is enforced everywhere.

Controlling Attribute Access

Getters and setters as written above are how Java and C# do it. Python has a better approach.

The problem with explicit getters and setters is noise. Every value needs two methods, and every read or write needs brackets:

The verbose way

class Student:
    def __init__(self, marks):
        self._marks = marks

    def get_marks(self):
        return self._marks

    def set_marks(self, value):
        if not 0 <= value <= 100:
            raise ValueError("marks must be 0 to 100")
        self._marks = value

asha = Student(78)

asha.set_marks(asha.get_marks() + 5)

print(asha.get_marks())

@property lets you keep the checking but use plain attribute syntax:

The Python way

class Student:
    def __init__(self, marks):
        self.marks = marks

    @property
    def marks(self):
        return self._marks

    @marks.setter
    def marks(self, value):
        if not 0 <= value <= 100:
            raise ValueError("marks must be 0 to 100")
        self._marks = value

asha = Student(78)

asha.marks += 5

print(asha.marks)

To the reader, asha.marks looks like a plain attribute. Behind the scenes, it runs a method. asha.marks += 5 reads the getter and writes through the setter, so the check still runs.

The stored value must not have the same name as the property. If you write self.marks = value inside the setter, you are assigning to the property, which calls the setter again, which calls the setter again, until Python stops with RecursionError. By convention, store it as self._marks.

Notice the constructor says self.marks = marks, not self._marks = marks. Using the property means the check runs at creation. If a bad value is passed to the constructor, it is caught there:

The check applies at creation too

class Student:
    def __init__(self, marks):
        self.marks = marks

    @property
    def marks(self):
        return self._marks

    @marks.setter
    def marks(self, value):
        if not 0 <= value <= 100:
            raise ValueError("marks must be 0 to 100")
        self._marks = value

Student(150)

A property with a getter and no setter is read-only. This is how you expose a value that is worked out from others:

A read-only property

class Student:
    def __init__(self, marks):
        self._marks = marks

    @property
    def average(self):
        return round(sum(self._marks) / len(self._marks), 1)

asha = Student([78, 85, 60])

print(asha.average)

asha.average = 100

AttributeError — average can be read and not set, which is correct. It is worked out from the marks, so setting it would make no sense.

The best part about @property is that you can add it later without breaking any code that uses the class:

Adding a rule without breaking callers

class Student:
    def __init__(self, marks):
        self.marks = marks

    @property
    def marks(self):
        return self._marks

    @marks.setter
    def marks(self, value):
        if not 0 <= value <= 100:
            raise ValueError("marks must be 0 to 100")
        self._marks = value

asha = Student(78)
print(asha.marks)

asha.marks = 85
print(asha.marks)

Every line using asha.marks is written exactly as it was when marks was a plain attribute. Python code can start with plain attributes and add a property only when a rule appears. There is no cost to waiting.

Start with a plain public attribute. Add @property the day a rule appears. Writing getters and setters for every value "just in case" is a habit from languages where changing your mind later would break every caller.

get_x() / set_x()@property
Readingobj.get_x()obj.x
Writingobj.set_x(5)obj.x = 5
Checking on writeyesyes
Read-only valuesleave out the setterleave out the setter
Can be added laterno — callers must changeyes — callers are untouched
Usual in Pythonnoyes

Here is everything together:

Everything together

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

    @property
    def balance(self):
        return self._balance

    @balance.setter
    def balance(self, value):
        if value < 0:
            raise ValueError("balance cannot be negative")
        self._balance = value

    @property
    def is_overdrawn(self):
        return self._balance == 0

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("deposit must be more than zero")
        self.balance = self._balance + amount

account = BankAccount("Asha", 100)

print(account.balance, account.is_overdrawn)

account.deposit(50)
print(account.balance)

try:
    account.balance = -5
except ValueError as error:
    print("Refused:", error)

balance is checked on every write, is_overdrawn is read-only and worked out when asked, and the caller writes plain attribute syntax throughout.

Common mistakes

Each row is one mistake. The middle column is what you will actually see on screen.

MistakeWhat you seeWhy it happensFix
A public attribute with rules about itImpossible values stored, failing laterNothing checks a plain assignmentUse @property with a setter
self._marks = marks in __init__ with a propertyBad values accepted at creationIt skips the setterself.marks = marks
Naming the property and the attribute the sameRecursionError: maximum recursion depth exceededThe setter calls itself for everStore in self._marks
Returning the real list from a getterA caller changes your dataLists are shared, not copiedReturn list(self._items)
Expecting _marks to be protectedAnyone can read and write itOne underscore is a convention onlyAccept it, or use a property
Expecting __marks to be secure_Class__marks is reachableName mangling renames, it does not lockTreat it as a convention
Getters and setters for every attributeNoisy code with no benefitA habit from other languagesStart with plain attributes
A setter that does not check anythingAll the noise, none of the protectionIt just copies the valueEither check, or use a plain attribute
Validating in several placesOne route gets missedThe rule is written more than onceMake every route go through the setter

What to Remember

  • Encapsulation means keeping data inside the class and controlling every way it can be changed.
  • Without it, an impossible value goes in silently and the failure appears somewhere else entirely.
  • One underscore means "internal, please leave alone", and Python does not enforce it.
  • A getter hands a value back; a setter changes it and is the place to check it.
  • Put the rule in one place and route every change through it, including the constructor.
  • @property is the Python way: attribute syntax outside, a method inside.
  • The stored value needs a different name — self._marks — or the setter calls itself for ever.
  • With a property, write self.marks = marks in __init__ so creation is checked too.
  • A property with no setter is read-only, which is right for a value worked out from others.
  • Start with plain attributes and add a property when a rule appears. Callers never have to change.
  • Return list(self._items) from a getter, so a caller cannot change your data from outside.

Next: Advanced OOP