DevHireLab
Tutorials
BootcampProblemsCode SimulatorAI InterviewSoonContact
DevHireLab
Tutorials
BootcampProblemsCode SimulatorAI InterviewSoonContact
Getting Started
01
What is Python and Why Learn It in 2026
02
Installing Python on Windows, Mac, and Linux
03
Setting up VS Code / PyCharm for Python
04
Understanding the Python Interpreter and REPL
05
Writing and Running Your First Python Program
06
Python Syntax and Indentation Rules
Variables and Data Types
01
Variables and Naming Conventions in Python
02
Numbers in Python (int, float, complex)
03
Strings and String Methods in Python
04
Booleans in Python
05
Type Conversion / Casting in Python
06
Understanding the None Type in Python
Operators
01
Arithmetic Operators in Python
02
Comparison Operators in Python
03
Logical Operators in Python
04
Assignment Operators in Python
05
Bitwise Operators in Python
06
Identity (is) and Membership (in) Operators
07
Walrus Operator (:=) in Python
Input, Output & Strings
01
input() and print() Functions in Python
02
f-strings and String Formatting in Python
03
String Slicing and Indexing in Python
04
Common String Methods (split, join, strip, replace)
05
Escape Characters in Python Strings
Control Flow
01
if, elif, else Statements in Python
02
Nested Conditionals in Python
03
for Loops in Python
04
while Loops in Python
05
break, continue, and pass Statements
06
Loop with else Clause in Python
07
match-case (Structural Pattern Matching)
Data Structures
01
Python Lists: Creation, Indexing, Slicing, Methods
02
Python Tuples and Immutability
03
Python Sets and Set Operations
04
Python Dictionaries: Keys, Values, and Methods
05
List Comprehensions in Python
06
Dictionary & Set Comprehensions in Python
07
Working with Nested Lists and Dictionaries
Functions
01
Defining and Calling Functions in Python
02
Function Arguments: Positional, Keyword, Default
03
*args and **kwargs in Python
04
Return Statements in Python Functions
05
Lambda Functions in Python
06
Recursion in Python
07
Variable Scope: Local, Global, Nonlocal
08
Docstrings and Function Annotations
Functional Programming Basics
01
map(), filter(), and reduce() in Python
02
Closures in Python Explained
03
Python Decorators: Basic to Advanced
04
Generators and yield in Python
Object-Oriented Programming (OOP)
01
Classes and Objects in Python
02
The init Constructor in Python
03
Instance vs Class Variables in Python
04
Inheritance in Python (Single, Multiple, Multilevel)
05
Polymorphism in Python
06
Encapsulation in Python
07
Abstraction in Python with the abc Module
08
Magic/Dunder Methods in Python
09
Static and Class Methods in Python
10
The @property Decorator in Python
11
Python Dataclasses Explained
Error Handling
01
try, except, finally in Python
02
Handling Multiple Exceptions in Python
03
Raising Custom Exceptions in Python
04
Understanding Python's Exception Hierarchy
05
Assertions in Python
File Handling
01
Reading and Writing Text Files in Python
02
Working with the with Statement in Python
03
Working with CSV Files in Python
04
Working with JSON Files in Python
05
Working with Directories (os, pathlib) in Python
Modules and Packages
01
Importing Built-in Modules in Python
02
Creating Your Own Modules in Python
03
Understanding Packages and init.py
04
Python Standard Library Overview
05
Installing Packages with pip
Advanced Core Concepts
01
Iterators and Iterables in Python
02
Context Managers in Python (with, contextlib)
03
Multithreading Basics in Python
04
Multiprocessing Basics in Python
05
Asyncio Basics: async/await in Python
06
Memory Management and Garbage Collection in Python
07
Type Hints in Python (typing module)

Encapsulation in Python

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 all
Protected 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 too

Nothing 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 name

The 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 negative
Adding 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 call

person.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 negative

person.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 attribute
Read-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 setter

area 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 = y

If 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 (__name wrapped 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.

PREVIOUSNEXT LESSON