Learn by reading through in order

Private Variables and Encapsulation — Safe Access via getter / setter

Learn Python's private variables and encapsulation. _x convention vs __x name mangling, get_xxx / set_xxx for safe access, and the Pythonic @property / @xxx.setter style — all hands-on.

Last time we covered the first two pillars of OOP — inheritance and polymorphism. This article wraps up the third: encapsulation.

Private Variables — Python Has No Real Privacy

Java and C++ have a private keyword that blocks outside access the moment you declare it. Python has no language-enforced privacy. Instead, the number of leading underscores signals "this is for internal use" or "don't touch this directly" — a convention between programmers, not a hard rule.

0 / 1 / 2 underscores convey intent
name(none)public attrfree to usefrom outside_name(1)convention:privatedon't touch__name(2)
The number of underscores doesn't change enforcement; it's just a label that signals intent.

Single underscore _x — convention-only privacy

Adding a single _ in front of a name tells the Python community "this attribute is for the class's internal use — don't access it directly from outside". The __init__ parameter still uses the plain name; only the self. field gets the leading _.

class UserAccount:
    def __init__(self, owner_name, balance):
        self._owner_name = owner_name      # internal -> prefix with _
        self._balance    = balance

    def get_info(self):                     # outward-facing accessor
        return {"owner": self._owner_name, "balance": self._balance}


user = UserAccount("Alice", 50000)
print(user._balance)        # 50000  <- works but not recommended
print(user.get_info())      # {'owner': 'Alice', 'balance': 50000}  <- recommended

Use a BlogPost class to feel what _ means (this is a different scenario from the sample above — same shape, different fields).

① Define class BlogPost: and assign self._title and self._views in __init__(self, title, views).

② Define summary(self) to return {"title": self._title, "views": self._views}.

③ Create post = BlogPost("Getting started with Python", 100). First, access directly with post._views and print it (works but is a forbidden pattern).

④ Then use the recommended patternprint(post.summary()).

(If your code runs correctly, the explanation will appear.)

Python Editor

Run code to see output

Double underscore __x — name mangling

Adding two leading _ makes Python rewrite the attribute name itself. If you write self.__pin = 1234 inside an Account class, the actual stored name becomes _Account__pin. This is called name manglingobj.__pin from outside doesn't find anything, so access is effectively much harder.

__pin gets rewritten to _Account__pin internally
self.__pin= 1234Python rewritesthe name_Account__pin= 1234obj.__pin-> Errorobj._Account__pin-> 1234store
Double-underscore attributes are stored under "underscore + class name + original name". Reading obj.__pin directly fails with AttributeError because that key isn't there.
class Account:
    def __init__(self, owner, pin):
        self._owner = owner       # convention-only privacy
        self.__pin  = pin         # name-mangled (becomes _Account__pin)

acc = Account("Alice", 1234)

print(acc._owner)              # Alice            <- works normally
# print(acc.__pin)             # AttributeError <- not visible directly
print(acc._Account__pin)       # 1234            <- mangled name reaches it

Use a LoginForm class to verify that __password really gets rewritten (different scenario from the Account sample, same behavior).

① Define class LoginForm: and in __init__(self, username, password) assign self._username = username and self.__password = password.

② Create form = LoginForm("alice", "p@ssw0rd"). Use print(form._username) to confirm the single-underscore side reads normally.

③ Use print(form._LoginForm__password) to retrieve the password via its mangled name.

④ Run print([n for n in dir(form) if not n.startswith('__')]) to dump the actual attribute names stored on the instance and see _LoginForm__password listed.

Python Editor

Run code to see output

"__" is not an absolute wall either

Double underscore prevents direct obj.__pin access, which is one notch stronger. But anyone who knows the mangled name obj._Account__pin can still reach it. It's not real privacy. In real projects, single underscore _x is far more common unless there's a specific reason to use mangling.

Encapsulation — restrict access to dedicated methods

Encapsulation is the design idea of "bundle data attributes and the methods that operate on them into one class, and force outside code to come through a small set of published entry points". So how do we build those entry points?

Funnel reads/writes through one method
consistencybrokendirect write_price = -100❌ outsideproduct._price= -100consistencypreservedset_price()validates✅ outsideproduct.set_price(100)as-isonly when OK
Direct writes let any value land in _price unchecked. Going through a method means the setter validates the type and range in one place.

The most basic style is to write get_xxx / set_xxx methods by hand. Inside the setter, do an isinstance check for the type and a range check, and raise ValueError(...) if anything's off. With that in place, no garbage value ever reaches _price.

class Product:
    def __init__(self, name, price, stock):
        self._name  = name
        self._price = price
        self._stock = stock

    def get_price(self):
        return self._price

    def set_price(self, price):
        if isinstance(price, int) and price >= 0:
            self._price = price
        else:
            raise ValueError("price must be a non-negative integer")


product = Product("T-shirt", 1500, 30)
print(product.get_price())     # 1500
product.set_price(2000)
print(product.get_price())     # 2000
# product.set_price(-100)      # ValueError

Add an age-only getter and setter to a registration-form class UserProfile. Age can't be negative or 200+, so the setter must guard the range.

① Define class UserProfile: and in __init__(self, name, age) assign _name and _age.

② Define get_age(self) and return self._age.

③ Define set_age(self, age). Only when age is int and 0 <= age <= 150 should it run self._age = age; otherwise raise ValueError("age must be an integer in [0, 150]").

④ Create user = UserProfile("Alice", 30), print user.get_age(), then user.set_age(31) and print the result again.

⑤ Confirm the rejection path: wrap user.set_age(-5) in try / except ValueError as e: and print("NG:", e).

Python Editor

Run code to see output

@property and @xxx.setter

The get_price() / set_price(...) style is clear, but the call sites end up looking method-call-y — not the cleanest. Python's more polished idiom uses two decorators: @property and @xxx.setter.

With those, the call site stays as product.price / product.price = 2000plain attribute access — but underneath, getter and setter methods are called. It's a two-layer structure where the syntax stays simple but logic still runs.

product.price looks the same; methods run underneath
product.price@propertydef pricereturnself._priceproduct.price= 2000@price.setterdef pricevalidate, thenupdate self._pricereadwrite
Attribute-access syntax stays as-is; @property redirects reads and @price.setter redirects writes into method calls. Validation lives inside the setter.
class Product:
    def __init__(self, name, price):
        self._name  = name
        self._price = price

    @property
    def price(self):                 # getter
        return self._price

    @price.setter
    def price(self, value):          # setter — name must match the getter
        if not isinstance(value, int) or value < 0:
            raise ValueError("price must be a non-negative integer")
        self._price = value

    @property
    def label(self):                 # computed property — derived value
        return f"{self._name} ({self._price})"


product = Product("T-shirt", 1500)
print(product.price)         # 1500              <- @property is invoked
product.price = 2000         # <- @price.setter is invoked
print(product.price)         # 2000
print(product.label)         # T-shirt (2000)    <- computed property

Keep the setter's name identical to the getter's

The price in @price.setter must match the method name from the previous @property def price. Python interprets the decorator as "attach a write version to the same price object that already has a read version" — if the name drifts, they're treated as separate things.

Rewrite the same UserProfile from Exercise 3 to use @property / @age.setter, and add a computed property age_group (under 18 / adult / senior).

① Define class UserProfile: with __init__(self, name, age) assigning _name / _age.

② Use @property def age(self): to return self._age.

③ Use @age.setter def age(self, value):; only when isinstance(value, int) and 0 <= value <= 150 assign self._age = value, else raise ValueError("age must be an integer in [0, 150]").

④ Use @property def age_group(self): to return "under 18" if self._age < 18, "adult" if self._age < 65, otherwise "senior" (no setter — read-only).

⑤ Create user = UserProfile("Alice", 30), print user.age and user.age_group, then user.age = 70 and print age_group once more.

Python Editor

Run code to see output

The Three Pillars of OOP

What encapsulation supports
Encapsulation
  • Data protection — split internal state from public API via _x
  • Consistency — concentrate validation in setters, one place
  • Implementation freedom — change internals without changing the public API
  • Pythonic style — convention plus @property, not language enforcement
Inheritance
  • Reuse a parent's machinery
Polymorphism
  • Same method name, different behavior per type
Encapsulation
  • Funnel outside access through a small set of doors
Inheritance, polymorphism, and encapsulation are OOP's three pillars. Inheritance reuses, polymorphism unifies the call sites, and encapsulation makes the whole thing hard to break — assigning each role consciously keeps the class design clean.
QUIZ

Knowledge Check

Answer each question one by one.

Q1Which is the most accurate statement about Python's private variables?

Q2What is the biggest benefit of using @property and @xxx.setter?

Q3Inside class Account: you wrote self.__pin = 1234. From outside, acc.__pin raises AttributeError. What name is actually stored on the instance?