Senior 7 min · March 05, 2026

Encapsulation in Python Explained — Access Control, Properties and Real-World Patterns

Encapsulation in Python demystified: learn why access control matters, how name mangling works, when to use properties, and the mistakes that trip up every beginner.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Python uses naming conventions, not access keywords: public, _protected, __private
  • Name mangling renames __attr to _ClassName__attr — prevents subclass collisions, not security
  • @property lets you add validation later without breaking callers who use obj.attr syntax
  • Performance cost: property getter/setter adds roughly 50ns overhead per call on CPython 3.12 — negligible unless in a hot inner loop; measure with timeit before optimising
  • Production trap: writing self.age = value inside an @age.setter causes infinite recursion — always use a private backing field like self.__age
  • Biggest mistake: treating __private like Java's private — it is still accessible via the mangled name _ClassName__attr
  • __slots__ restricts which attributes can be set on an instance, prevents arbitrary attribute assignment, and halves memory per instance — worth knowing alongside properties
Plain-English First

Think of your bank account. The bank lets you deposit and withdraw money through a teller or ATM — but you cannot walk into the vault and grab cash directly. The rules around HOW you interact with the money are enforced. Encapsulation is exactly that: your object's data is the vault, and the methods are the teller window. You control what gets in, what gets out, and what rules apply. The teller knows the rules so you do not have to check them yourself before every transaction — you just ask for what you want and the teller either does it or tells you why not.

Every non-trivial Python codebase eventually breaks down the same way: one part of the code quietly reaches into an object and changes a value it was never supposed to touch. The result is not always an immediate crash — it is a slow corruption that surfaces three function calls later as a mysterious bug. That is the exact problem encapsulation was designed to prevent, and it is why every serious OOP language treats it as a first-class concern.

Encapsulation bundles data and the logic that operates on that data into a single unit — the class — and then controls how the outside world interacts with it. Instead of letting any caller freely read or overwrite an object's internals, you expose a deliberate interface. The implementation details can change completely without breaking the callers, because the callers were never depending on those details in the first place. That is not just good practice; it is what makes software maintainable at scale.

By the end of this article you will understand the difference between Python's three levels of visibility, why name mangling exists and when it actually helps, how to use properties to add validation without breaking your API, what __slots__ gives you beyond properties, and the encapsulation mistakes that show up in nearly every code review. You will also walk away with the mental model interviewers are actually testing when they ask about this topic.

Python's Three Levels of Visibility — and What They Actually Mean

Python does not have hard private or protected keywords like Java or C++. Instead it uses a naming convention that signals intent — and one that has real runtime consequences. There are three levels you need to know.

Public (self.name): accessible from anywhere. No underscore. This is your deliberate API — the things you want callers to use.

Protected (self._name): a single leading underscore. Python will not stop anyone from accessing it, but the underscore is a social contract that says this is an internal detail — do not depend on it from outside this class or its subclasses. Linters and experienced developers will respect it.

Private (self.__name): a double leading underscore triggers name mangling — Python renames the attribute under the hood so it cannot accidentally be overridden by a subclass. This is NOT a security feature; it is a namespace collision guard.

Understanding this hierarchy is the foundation of everything else. A practical guide to choosing the right level:

  • Use public when the attribute is part of your deliberate API and any value is acceptable.
  • Use protected when the attribute is an internal detail that subclasses may legitimately need to read or extend.
  • Use private when you have a concrete inheritance collision risk — a base class attribute that subclasses must never accidentally shadow.
  • Use @property when you need validation, a computed value, or a read-only guarantee on something that callers access with attribute syntax.

Notice that none of these choices are about security. Python has no truly private data. They are about communicating intent and preventing accidents.

visibility_levels.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class BankAccount:
    def __init__(self, owner: str, initial_balance: float):
        # PUBLIC: intended to be read by anyone
        self.owner = owner

        # PROTECTED: internal bookkeeping — subclasses may need it,
        # but outside callers should not rely on it.
        # The single underscore is a social contract, not a lock.
        self._transaction_history = []

        # PRIVATE: name mangling kicks in here.
        # Python renames this to _BankAccount__balance internally.
        # Subclasses cannot accidentally stomp on it.
        self.__balance = initial_balance

    def get_balance(self) -> float:
        """The only sanctioned way to read the balance."""
        return self.__balance

    def deposit(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("Deposit amount must be positive.")
        self.__balance += amount
        self._transaction_history.append(f"Deposit: +{amount}")


account = BankAccount("Alice", 1000.0)
account.deposit(250.0)

print(account.owner)                    # Public — totally fine
print(account.get_balance())            # Public method — fine
print(account._transaction_history)     # Works, but the underscore means: please do not

# Accessing the private attribute directly:
try:
    print(account.__balance)            # AttributeError — mangled name is different
except AttributeError as error:
    print(f"Direct access blocked: {error}")

# Name mangling in action — the attribute still exists, just renamed:
# This is how you confirm it exists in debugging. Never do this in production.
print(account._BankAccount__balance)
Output
Alice
1250.0
['Deposit: +250.0']
Direct access blocked: 'BankAccount' object has no attribute '__balance'
1250.0
Watch Out: Double Underscore Is Not a Lock
Name mangling renames self.__balance to self._BankAccount__balance — it does NOT make the data inaccessible. Any caller with vars(obj) can see the mangled name and access it. Treat double underscore as 'this is not part of my public API, hands off' — not as encryption or security. Python's privacy model is built on trust and convention, not enforcement.
Production Insight
Name mangling prevents subclass collisions but does not secure data. If you rely on double underscores for access control, any developer with vars(obj) can bypass it in seconds. Use double underscores for inheritance namespace safety, not for protecting sensitive values. Sensitive values belong in secure storage, not in Python object attributes.
Key Takeaway
Python's visibility is convention-based, not compiler-enforced. Single underscore means please do not touch. Double underscore means you cannot accidentally touch — but you can still deliberately touch via the mangled name. Neither stops a determined developer; they only prevent accidents.

Properties — Adding Validation Without Breaking Your API

Here is a real scenario: you ship a UserProfile class where age is a plain public attribute. Six months later, a bug report lands — someone stored age = -5. You need to add validation. If you add a method called set_age(), every single line of code that wrote user.age = value now breaks. That is a terrible trade-off.

Python's @property decorator solves this elegantly. It lets you start with a plain attribute and later introduce a getter, setter, and deleter — without changing the calling syntax at all. The callers still write user.age = 25 and print(user.age). They never know you swapped in a method behind the scenes.

This is the Pythonic way to enforce encapsulation: start simple with a public attribute, and graduate to a property only when you need the control. Do not defensively wrap everything in getters and setters from day one — that is Java thinking in a Python codebase and it creates noise without benefit.

The most important rule about property setters: never assign to the property name itself inside the setter. Writing self.age = value inside @age.setter calls the setter again, creating infinite recursion and a RecursionError. Always use a private backing field like self.__age. This is the number one property bug in junior and even mid-level Python code.

user_profile_property.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
class UserProfile:
    def __init__(self, username: str, age: int):
        self.username = username   # Still public — no validation needed here
        self.age = age             # This calls the setter defined below
                                   # so validation runs at construction too

    @property
    def age(self) -> int:
        """The getter — called whenever you READ user.age"""
        return self.__age          # Returns the private backing field

    @age.setter
    def age(self, value: int) -> None:
        """The setter — called whenever you WRITE user.age = something.

        CRITICAL: we assign to self.__age, NOT self.age.
        Writing self.age = value here would call this setter again
        and cause infinite recursion (RecursionError).
        """
        if not isinstance(value, int):
            raise TypeError(f"Age must be an integer, got {type(value).__name__}.")
        if value < 0 or value > 130:
            raise ValueError(f"Age {value} is outside the valid range (0-130).")
        self.__age = value         # Store in the private backing field

    @age.deleter
    def age(self) -> None:
        """The deleter — called when you do 'del user.age'"""
        raise AttributeError("Deleting age is not allowed — use age = 0 to reset.")

    def __repr__(self) -> str:
        return f"UserProfile(username={self.username!r}, age={self.__age})"


# Normal usage — looks identical to a plain attribute
alice = UserProfile("alice99", 28)
print(alice)                # __repr__ is called

alice.age = 29              # Calls the setter transparently
print(alice.age)            # Calls the getter

# Validation in action
try:
    alice.age = -1
except ValueError as error:
    print(f"Validation caught it: {error}")

try:
    alice.age = "twenty"
except TypeError as error:
    print(f"Type check caught it: {error}")

try:
    del alice.age
except AttributeError as error:
    print(f"Delete blocked: {error}")
Output
UserProfile(username='alice99', age=28)
29
Validation caught it: Age -1 is outside the valid range (0-130).
Type check caught it: Age must be an integer, got str.
Delete blocked: Deleting age is not allowed — use age = 0 to reset.
Pro Tip: Start Public, Refactor to Property — Zero Breaking Change
Python's style guide (PEP 8) recommends starting with a plain public attribute. Only convert to a @property when you have a concrete reason: input validation, a computed or derived value, lazy loading, or read-only enforcement. Python's @property is a zero-breaking-change refactor — callers using obj.attribute syntax never notice the difference. There is no reason to add properties defensively upfront.
Production Insight
The no-break refactor promise of @property only holds if callers use dot-access syntax. If any caller directly manipulates obj.__dict__['age'], the property is bypassed entirely. Never assign to __dict__ keys that correspond to properties. This is rare but shows up in deserialization code and ORMs — audit those paths when converting a plain attribute to a property.
Key Takeaway
Properties let you evolve a public attribute into a validated one without breaking callers. The bigger the codebase, the more valuable this escape hatch becomes. Start simple and use @property when the data earns it.

A Real-World Pattern — The Configuration Manager

Theory is useful; seeing encapsulation solve an actual design problem is better. Here is a pattern you will encounter constantly in production Python: a configuration object that loads settings from environment variables, validates them, and exposes a clean read-only interface to the rest of the application.

Without encapsulation, every part of the app reads environment variables directly, re-validates them independently, and scatters os.environ.get(...) calls everywhere. Change a variable name and you are hunting across the entire codebase. With encapsulation, one class owns configuration. Everything else asks that class.

This pattern also demonstrates computed properties — values derived from private data rather than stored directly — and shows why hiding the implementation lets you change it later (switching from env vars to a config file, for example) without touching any caller.

Notice that __validate_env is a classmethod here, not a plain instance method. It only needs access to the class-level SUPPORTED_ENVIRONMENTS constant, not to any instance data, so classmethod is the accurate declaration. This is a detail that distinguishes code written for correctness from code written to pass a linter.

app_config.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import os

class AppConfig:
    """
    Single source of truth for application configuration.
    Validates settings at construction time so the rest of the app
    can trust that any AppConfig instance is always in a valid state.
    """

    # Class-level constant — shared across all instances
    SUPPORTED_ENVIRONMENTS = {"development", "staging", "production"}

    def __init__(self):
        # Private: raw values loaded once at startup
        self.__db_host = self.__require_env("DB_HOST")
        self.__db_port = self.__parse_port(os.environ.get("DB_PORT", "5432"))
        self.__env_name = self.__validate_env(os.environ.get("APP_ENV", "development"))
        self.__debug_mode = os.environ.get("DEBUG", "false").lower() == "true"

    # --- Private helpers: implementation details, not part of the API ---

    @staticmethod
    def __require_env(key: str) -> str:
        """Blows up early with a clear message if a required variable is missing."""
        value = os.environ.get(key)
        if value is None:
            raise EnvironmentError(
                f"Required environment variable '{key}' is not set. "
                f"Check your .env file or deployment config."
            )
        return value

    @staticmethod
    def __parse_port(raw_value: str) -> int:
        """Ensures the port is a valid integer in a usable range."""
        try:
            port = int(raw_value)
        except ValueError:
            raise ValueError(f"DB_PORT must be an integer, got: {raw_value!r}")
        if not (1 <= port <= 65535):
            raise ValueError(f"DB_PORT {port} is outside valid range (1-65535).")
        return port

    @classmethod
    def __validate_env(cls, env_name: str) -> str:
        """Guards against typos in APP_ENV.

        Declared as classmethod because it only needs SUPPORTED_ENVIRONMENTS,
        a class-level constant — no instance data required.
        """
        if env_name not in cls.SUPPORTED_ENVIRONMENTS:
            raise ValueError(
                f"APP_ENV must be one of {cls.SUPPORTED_ENVIRONMENTS}, got: {env_name!r}"
            )
        return env_name

    # --- Public read-only properties: the deliberate API ---

    @property
    def database_url(self) -> str:
        """Computed property — assembles the URL on demand from private parts."""
        return f"postgresql://{self.__db_host}:{self.__db_port}/appdb"

    @property
    def is_production(self) -> bool:
        return self.__env_name == "production"

    @property
    def debug_enabled(self) -> bool:
        return self.__debug_mode

    def __repr__(self) -> str:
        # Safe to log — never exposes raw credentials
        return (
            f"AppConfig(env={self.__env_name!r}, "
            f"db_host={self.__db_host!r}, "
            f"debug={self.__debug_mode})"
        )


# --- Simulating environment variables for the demo ---
os.environ["DB_HOST"] = "db.internal.myapp.com"
os.environ["DB_PORT"] = "5432"
os.environ["APP_ENV"] = "development"
os.environ["DEBUG"] = "true"

config = AppConfig()
print(config)                    # Safe repr — no credentials exposed
print(config.database_url)       # Computed on the fly
print(config.is_production)      # False in development
print(config.debug_enabled)      # True

# The outside world cannot overwrite the database URL — no setter defined
try:
    config.database_url = "postgresql://evil-host:1234/hack"
except AttributeError as error:
    # Python 3.11+: "property 'database_url' of 'AppConfig' object has no setter"
    # Python 3.10 and earlier: "can't set attribute"
    print(f"Read-only enforced: {error}")
Output
AppConfig(env='development', db_host='db.internal.myapp.com', debug=True)
postgresql://db.internal.myapp.com:5432/appdb
False
True
Read-only enforced: property 'database_url' of 'AppConfig' object has no setter
Interview Gold: The Fail Fast Principle
Notice how AppConfig validates everything in __init__. This is fail-fast encapsulation — the object either comes into existence in a fully valid state, or it raises immediately with a clear message. This is far better than an object that lets you construct it with bad data and then crashes somewhere unrelated at runtime, often in a different service or at 3 AM. The entire application can then trust that any AppConfig instance is safe to use, with no defensive checks scattered across callers.
Production Insight
Fail-fast construction is the single most effective encapsulation pattern for config objects. If a setting is invalid, you learn about it during startup, not when the app hits that code path six hours into a deployment. The trade-off is slightly longer startup time because every env var is checked — but that is a cheap price for reliability. The Python 3.11+ error message for read-only properties changed from 'can't set attribute' to a more specific message — if your tests assert on that string, update them.
Key Takeaway
Encapsulate configuration behind an object that validates on construction. The entire app then trusts that any instance is safe to use. Fail fast — never let an invalid object come into existence.

Encapsulation With Inheritance — Where Name Mangling Earns Its Keep

Name mangling feels like a quirk until you see the exact problem it prevents. Imagine a base class that tracks an internal __modification_count for auditing. A subclass, completely unaware of the base class's internals, also uses __modification_count for something entirely different. Without mangling, they collide on the same attribute and corrupt each other silently.

With mangling, Base.__modification_count lives at _Base__modification_count and Child.__modification_count lives at _Child__modification_count — they coexist without conflict. The subclass never has to know the base class even has such an attribute. That is the actual purpose of double underscores: preventing accidental namespace collisions in inheritance hierarchies, not locking data away from determined callers.

The proof is simple: call vars(instance) on any object and every mangled name is visible. There is no hiding. The naming convention is about accidents, not access control.

This distinction separates developers who memorise the syntax from those who understand the design decision behind it — which is exactly what an interviewer is probing for when they ask about name mangling.

inheritance_mangling.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
class AuditedEntity:
    """
    Base class that tracks how many times any method modifies the object.
    Uses __modification_count so subclasses cannot accidentally collide with it.
    """

    def __init__(self):
        self.__modification_count = 0   # Stored as _AuditedEntity__modification_count

    def _record_modification(self) -> None:
        """Protected helper — subclasses call this after any mutation."""
        self.__modification_count += 1

    def get_audit_count(self) -> int:
        return self.__modification_count


class InventoryItem(AuditedEntity):
    """
    Subclass with its own internal quantity attribute.
    Without name mangling, if it also defined __modification_count,
    it would silently corrupt the base class audit counter.
    Name mangling prevents that collision entirely.
    """

    def __init__(self, product_name: str, quantity: int):
        super().__init__()
        self.product_name = product_name
        self.__quantity = quantity           # Stored as _InventoryItem__quantity

    def restock(self, units: int) -> None:
        if units <= 0:
            raise ValueError("Units to restock must be positive.")
        self.__quantity += units
        self._record_modification()          # Tells the base class: a change happened

    def sell(self, units: int) -> None:
        if units > self.__quantity:
            raise ValueError(f"Cannot sell {units} units; only {self.__quantity} in stock.")
        self.__quantity -= units
        self._record_modification()

    def get_quantity(self) -> int:
        return self.__quantity

    def __repr__(self) -> str:
        return (
            f"InventoryItem(product={self.product_name!r}, "
            f"qty={self.__quantity}, "
            f"modifications={self.get_audit_count()})"
        )


widget = InventoryItem("Widget Pro", 100)
widget.restock(50)    # qty -> 150, audit_count -> 1
widget.sell(30)       # qty -> 120, audit_count -> 2
widget.sell(10)       # qty -> 110, audit_count -> 3

print(widget)
print(f"Audit trail shows {widget.get_audit_count()} modifications.")

# vars() proves both __counts live in completely separate namespaces:
print(vars(widget))
Output
InventoryItem(product='Widget Pro', qty=110, modifications=3)
Audit trail shows 3 modifications.
{'_AuditedEntity__modification_count': 3, 'product_name': 'Widget Pro', '_InventoryItem__quantity': 110}
Pro Tip: Use vars() to Demystify Name Mangling
When you are confused about where an attribute lives, call vars(your_object). It returns the instance's __dict__ with all the mangled names visible. It is the best single debugging tool for understanding what Python is actually storing. If you ever need to understand a third-party class's internals, vars() and dir() together reveal everything.
Production Insight
Name mangling prevents subtle bugs in deep class hierarchies, but it adds complexity to debugging. If you use __private attributes, document that they are mangled — or your team will waste hours tracing mysterious AttributeErrors. Use double underscores sparingly: only when you have a concrete inheritance collision risk, not as a default access-control choice.
Key Takeaway
Name mangling exists for inheritance safety, not for secrecy. Subclasses cannot accidentally shadow base class internals because Python renames them to class-specific keys. vars(obj) reveals all mangled names — there is no true privacy, only accident prevention.

Encapsulation and the Tell, Don't Ask Principle

A common indicator of weak encapsulation is when code outside a class queries the object's state and then decides what to do based on that state. The classic example is checking the balance before withdrawing. That is asking the object for its data so the caller can decide. Better design is to tell the object what you want and let it decide internally.

Tell, Don't Ask is the encapsulation litmus test. If you find yourself writing code that reads an attribute and then conditionally calls a method based on its value, that conditional logic probably belongs inside the method. The caller should not need to know the rule; it just sends a request and handles success or failure.

This matters beyond style. Consider a threaded application: reading balance from one thread and then calling withdraw from the same thread introduces a time-of-check to time-of-use (TOCTOU) window where another thread could change the balance between the check and the action. When the validation lives inside withdraw, the check and the action are atomic at the method level.

The two accounts in the example below are intentionally separate to make each pattern's behaviour independently clear.

tell_dont_ask.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class BankAccount:
    def __init__(self, account_holder: str, initial_balance: float):
        self.holder = account_holder
        self.__balance = initial_balance

    @property
    def balance(self) -> float:
        return self.__balance

    def withdraw(self, amount: float) -> None:
        """
        Tries to withdraw amount. Raises ValueError if insufficient funds.
        The caller does not need to check balance beforehand.
        Validation is here, not scattered across callers.
        """
        if amount <= 0:
            raise ValueError("Withdrawal amount must be positive.")
        if amount > self.__balance:
            raise ValueError(
                f"Insufficient funds. Available: {self.__balance:.2f}, Requested: {amount:.2f}"
            )
        self.__balance -= amount

    def deposit(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("Deposit amount must be positive.")
        self.__balance += amount


# --- Anti-pattern: asking and then acting (separate account for clarity) ---
bad_account = BankAccount("Bob (anti-pattern demo)", 1000.0)
print(f"Balance before: {bad_account.balance:.2f}")

# The caller checks state and decides — this is the anti-pattern.
# Two problems:
# 1. The business rule (amount <= balance) is now duplicated in every caller.
# 2. In a multithreaded context, another thread could change balance
#    between the check on line N and the withdraw on line N+1 (TOCTOU bug).
if bad_account.balance >= 300:
    bad_account.withdraw(300)
    print("Anti-pattern withdraw succeeded (balance check done by caller).")

# --- Good encapsulation: tell the object what you want (separate account) ---
good_account = BankAccount("Alice (good pattern demo)", 1000.0)

# The object decides if the withdrawal is allowed.
# Business logic lives in one place. Caller handles success or failure.
good_account.withdraw(400)
print(f"Good pattern: withdrew 400. Remaining: {good_account.balance:.2f}")

try:
    good_account.withdraw(800)  # Will fail — object enforces the rule
except ValueError as error:
    print(f"Object enforced its rule: {error}")
Output
Balance before: 1000.00
Anti-pattern withdraw succeeded (balance check done by caller).
Good pattern: withdrew 400. Remaining: 600.00
Object enforced its rule: Insufficient funds. Available: 600.00, Requested: 800.00
Tell, Don't Ask in One Sentence
If you read an attribute and then call a method based on its value, the condition belongs inside the method. The caller should not know the rule — it should only know what it wants to do.
Production Insight
The Tell, Don't Ask anti-pattern is the leading cause of scattered business logic in production Python codebases. When every caller inlines its own balance check, a rule change requires hunting through dozens of files. When the rule lives inside withdraw, changing it means changing one method. The pattern also eliminates a subtle class of threading bugs where state is checked and acted on in separate, non-atomic steps.
Key Takeaway
Do not ask an object for its data to make a decision. Tell the object what you want and let it enforce its own rules. This is encapsulation in action — behavior and data live together.

__slots__ — Restricting Attributes and Saving Memory

Most Python developers learn about public, protected, private, and @property. Fewer know about __slots__, and that gap matters in 2026 when memory-efficient Python services are increasingly common.

By default, every Python instance stores its attributes in a dictionary (__dict__). That dictionary is flexible — you can add any attribute at any time — but it has overhead: roughly 200–400 bytes per instance depending on the Python version and platform, plus the cost of hash table operations on every attribute access.

__slots__ replaces that per-instance dictionary with a fixed set of slots defined at class creation. The benefits are concrete:

  • Memory: instances with __slots__ typically use 40–50% less memory than their __dict__-based equivalents.
  • Speed: attribute access is slightly faster because it uses a direct offset rather than a hash lookup.
  • Safety: trying to set an attribute not listed in __slots__ raises AttributeError immediately, which prevents the kind of typo bug where self.nmae = value silently creates a new attribute instead of setting self.name.

The cost is flexibility: you cannot add arbitrary attributes to an instance at runtime. This is usually a feature, not a limitation.

__slots__ interacts with properties naturally — you declare the slot for the private backing field, and the property descriptor lives on the class as usual. You do not slot the property name itself.

When should you reach for __slots__? The practical answer is: when you create many instances of a class (thousands or more) and memory matters, or when you want a hard guarantee that no unexpected attributes can be assigned to instances. Configuration objects, data transfer objects, and domain model entities are all good candidates.

slots_encapsulation.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import sys

# --- Without __slots__: default dict-based instance storage ---
class ProductWithDict:
    def __init__(self, name: str, price: float):
        self.name = name
        self.__price = price

    @property
    def price(self) -> float:
        return self.__price


# --- With __slots__: fixed attribute slots ---
class ProductWithSlots:
    __slots__ = ('name', '_ProductWithSlots__price')  # slot for property backing field

    def __init__(self, name: str, price: float):
        self.name = name
        self.__price = price  # stored in the slot, not __dict__

    @property
    def price(self) -> float:
        return self.__price


# Memory comparison
dict_product = ProductWithDict("Widget", 9.99)
slot_product = ProductWithSlots("Widget", 9.99)

print(f"With __dict__:  {sys.getsizeof(dict_product.__dict__)} bytes for __dict__ alone")
print(f"With __slots__: no __dict__ — slots live in the class structure")
print(f"Instance size (dict):  {sys.getsizeof(dict_product)} bytes")
print(f"Instance size (slots): {sys.getsizeof(slot_product)} bytes")

# Safety demonstration: __slots__ prevents accidental attribute creation
try:
    slot_product.unexpected_attr = "this should not exist"
except AttributeError as error:
    print(f"\nSlots safety caught it: {error}")

# Properties still work exactly the same way through slots
print(f"\nPrice via property: {slot_product.price}")

# Normal dict-based class allows accidental attributes silently
dict_product.unexpected_attr = "oops — a typo created a new attribute"
print(f"Dict class allows accidental attr: {dict_product.unexpected_attr}")
Output
With __dict__: 232 bytes for __dict__ alone
With __slots__: no __dict__ — slots live in the class structure
Instance size (dict): 48 bytes
Instance size (slots): 56 bytes
Slots safety caught it: 'ProductWithSlots' object has no attribute 'unexpected_attr'
Price via property: 9.99
Dict class allows accidental attr: oops — a typo created a new attribute
__slots__ Is an Encapsulation Tool, Not Just a Memory Trick
The memory saving is real and measurable at scale. But the more underappreciated benefit is safety: __slots__ makes it impossible to accidentally create a new attribute through a typo, which is a subtle class of bug that plain __dict__ classes are vulnerable to. Consider __slots__ for any class where instance count is large, attribute set is known and fixed, or accidental attribute creation would be a hard-to-detect bug.
Production Insight
At scale, the memory difference between __dict__ and __slots__ compounds quickly. Ten thousand product instances at 200 bytes of __dict__ overhead each is two megabytes of pure bookkeeping. In a microservice that processes millions of requests, the allocator pressure is real. Benchmark with tracemalloc or memory_profiler before assuming the overhead is negligible.
Key Takeaway
__slots__ restricts attribute creation to a declared set, reduces memory by 40 to 50 percent per instance, and prevents accidental attribute typos. Use it for data-heavy classes where the attribute set is known and fixed. Properties work naturally alongside slots — slot the backing field, not the property name.
● Production incidentPOST-MORTEMseverity: high

The Silent Data Corruption That Traced Back to a Missing Property Setter

Symptom
Reports of users with age=-5, height=0.0 showing up in analytics. No crashes, no exceptions — just garbage data polluting dashboards and ML pipelines.
Assumption
The team assumed the frontend validation was sufficient. The attitude was: we check ages and heights in the React form, so the backend does not need to.
Root cause
The UserProfile class used plain public attributes self.age and self.height. A downstream batch job pushed raw data directly to the API endpoint, bypassing the frontend entirely. No server-side validation existed because the attributes were directly writable with no restriction.
Fix
Converted both attributes to @property with setters that validate ranges and raise ValueError immediately. Also updated __init__ to assign through the setters rather than directly to the backing fields, so validation runs at construction time too. No invalid UserProfile object can now exist in memory.
Key lesson
  • Never trust client-side validation as your only defense — API encapsulation must enforce invariants server-side.
  • Public attributes are an implicit promise: this value is always safe to write. Only use them when you are genuinely willing to accept any value.
  • Fail fast in __init__: validate early so no invalid object ever exists in memory.
  • The cost of converting a plain attribute to a property is zero for callers — Python's @property is a zero-breaking-change refactor. There is no excuse for leaving validation out once you know it is needed.
Production debug guideSymptom to action guide for the most common encapsulation failures in Python production code.5 entries
Symptom · 01
AttributeError: 'MyClass' object has no attribute '__private_attr'
Fix
Remember name mangling. The attribute is stored as _MyClass__private_attr. Use vars(obj) to see all mangled names. Access via the mangled name for debugging only — never in production code. If this error surfaces in production, the caller is accessing an internal attribute by its unmangled name, which means the class boundary was leaked somewhere.
Symptom · 02
RecursionError when setting a property
Fix
You stored the value using the property name inside the setter — for example self.age = value instead of self.__age = value. This calls the setter again, creating infinite recursion. Fix: use a private backing field with a different name. The convention is self.__age for the backing field behind a property named age.
Symptom · 03
Subclass accidentally overrides base class internal attribute, causing logic corruption
Fix
Check if both base and subclass use double-underscore attributes with the same name. Without mangling they share the same slot. Use __attr in the base class to ensure mangling separates them. Verify with vars(base_obj) and vars(sub_obj) to see the actual stored names.
Symptom · 04
Property getter or setter is never called
Fix
Confirm you are accessing through the property name, not the backing field. Writing obj.__age directly skips the getter entirely. Use obj.age instead. Also verify that nothing in the code assigns directly to obj.__dict__['age'] — that also bypasses the property descriptor.
Symptom · 05
Unexpected AttributeError when setting an attribute on a class that uses __slots__
Fix
__slots__ restricts which attributes can exist on an instance. If you try to set an attribute not listed in __slots__, Python raises AttributeError. Check the slots definition on the class and its parents. If you need to add a new attribute, add it to __slots__ explicitly.
★ Quick Debug Cheat Sheet for EncapsulationFix common encapsulation bugs fast with these commands and patterns.
Infinite recursion in property setter
Immediate action
Stop the Python process and inspect the setter code. Look for any line that assigns to the property name itself.
Commands
grep -n 'self\.age\s*=' user_profile.py
python -c 'import inspect; print(inspect.getsource(obj.__class__.age.fset))'
Fix now
Change self.age = value to self.__age = value inside the setter. Never assign to the property name itself inside its own setter.
Cannot find the actual attribute name for debugging+
Immediate action
Print all instance attributes with their mangled names.
Commands
python -c 'print(vars(obj))'
python -c 'print([a for a in dir(obj) if not a.startswith("__")])'
Fix now
Use the mangled name shown in vars() to access the value directly for inspection only. Never rely on mangled names in production code.
Property getter or setter not triggered+
Immediate action
Check if you are using the correct attribute name and that nothing bypasses the descriptor.
Commands
type(obj).age.fget
python -c 'print(obj.__dict__)'
Fix now
Remove any direct dict assignment. Ensure you use obj.age = value and never obj.__dict__['age'] = value.
Subclass and base class attribute collision+
Immediate action
Inspect both classes' __dict__ after instantiation to confirm the actual stored keys.
Commands
python -c "print(vars(obj)); print(type(obj).__mro__)"
python -c "print([attr for attr in dir(obj) if 'count' in attr])"
Fix now
Use double underscores in the base class to trigger mangling and give each class its own namespace for that attribute.
Python Access Control Levels: Side-by-Side
AspectPublic (`name`)Protected (`_name`)Private (`__name`)@property__slots__
Access from outside classFully intendedWorks but discouraged by conventionRaises AttributeError on direct access; accessible via _ClassName__nameVia property syntax (obj.name) — controlled by getterNormal attribute access if declared; AttributeError if not in slots
Access from subclassYesYes — the intended use caseVia mangled name _ClassName__name onlyInherited unless overriddenInherited slots are accessible; new slots must be declared in subclass
Name mangling appliedNoNoYes — renamed to _ClassName__nameNo — property is a class descriptorNo — slot names are stored as-is
Validation possibleNoNoNoYes — via setterNo — combine with property for validation
Memory overheadNormal (__dict__)Normal (__dict__)Normal (__dict__)Normal (__dict__) for backing field40-50% lower — no per-instance __dict__
Prevents accidental attribute creationNoNoNoNoYes — AttributeError on unknown attribute
Use caseDeliberate public APIInternal detail for subclassesCollision-proofing in deep hierarchiesValidation, computed values, read-only enforcementMemory-efficient classes with fixed attribute sets

Key takeaways

1
Python uses naming conventions, not access modifiers
single underscore means internal, handle with care; double underscore triggers name mangling to prevent inheritance namespace collisions, not to enforce privacy.
2
@property is your refactoring escape hatch
start with a plain public attribute, graduate to a property only when you need validation or computed values — the calling syntax stays identical so no callers break.
3
Name mangling renames self.__attr to self._ClassName__attr at runtime
it is a collision guard for inheritance hierarchies, not a security lock. vars(obj) will always reveal the truth.
4
The strongest encapsulation pattern is fail fast in __init__
validate all inputs at construction time so any instance of your class is guaranteed to be in a valid state for its entire lifetime.
5
Tell, Don't Ask
do not check an object's state before calling a method; let the object decide. This keeps business logic encapsulated and eliminates scattered duplicate checks and TOCTOU bugs.
6
__slots__ restricts attribute creation to a declared set, reduces memory by 40 to 50 percent per instance, and prevents accidental attribute typos at runtime. Use it for data-heavy classes with a known, fixed attribute set.

Common mistakes to avoid

6 patterns
×

Creating a property backing field with the same name as the property

Symptom
Writing self.age = value inside an @age.setter instead of self.__age = value causes infinite recursion. The setter calls itself forever and you get a RecursionError.
Fix
Always store the value in a private backing attribute like self.__age or self._age, never in the property name itself. This is the most common property bug in Python code.
×

Trying to use __dunder__ names for private attributes

Symptom
Double underscores on BOTH sides (like self.__value__) are reserved for Python magic methods. Name mangling does NOT apply to them — Python sees them as special protocol names. Your intended private attribute becomes a public magic name.
Fix
Use self.__value (leading double, no trailing double) for private attributes. Only Python internals use the dunder pattern on both sides.
×

Over-engineering by wrapping every attribute in a property from day one

Symptom
Beginners coming from Java instinctively write get_name() and set_name() methods for every attribute, or create a @property for everything. This creates three times the code with zero benefit until validation is actually needed.
Fix
Start with a plain public attribute. Refactor to a property only when you have a concrete reason. Python's @property makes this refactor non-breaking, so there is no reason to do it prematurely.
×

Assuming name mangling provides security or true privacy

Symptom
Developers store sensitive data in __private attributes, believing they are inaccessible. Any developer with vars(obj) can access them via obj._ClassName__secret.
Fix
Use name mangling only for inheritance namespace collision prevention. Sensitive data belongs in secure storage, not Python object attributes.
×

Forgetting that @property is bypassed by direct __dict__ assignment

Symptom
Validation in a property setter never fires because some deserialization or ORM layer assigns directly to obj.__dict__['age'] = value, completely bypassing the descriptor protocol.
Fix
Audit any code that uses __dict__ assignment for your model classes. If you control the deserialization path, always assign through the attribute name (obj.age = value) so the property setter runs.
×

Declaring __slots__ without accounting for the private backing field name

Symptom
A class uses __slots__ and a @property, but the backing field (self.__price) causes AttributeError because the mangled name _ClassName__price is not in the slots declaration.
Fix
When using __slots__ with private backing fields, include the mangled name in the slots tuple. For a class Foo with self.__price, add '_Foo__price' to __slots__.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the actual purpose of Python's name mangling with double undersc...
Q02JUNIOR
What happens if you write self.age = value inside an @age.setter, and ho...
Q03SENIOR
If you start a class with a plain public attribute user.age and later ne...
Q04SENIOR
What is the difference between a _protected attribute and a __private at...
Q05SENIOR
How does the Tell, Don't Ask principle relate to encapsulation? Provide ...
Q01 of 05JUNIOR

What is the actual purpose of Python's name mangling with double underscores — and why is it NOT the same as making an attribute truly private?

ANSWER
Name mangling renames __attr to _ClassName__attr at the bytecode level. Its purpose is to prevent accidental attribute collisions in class hierarchies — not to enforce privacy. The attribute can still be accessed via the mangled name from anywhere outside the class, so it is not private in the Java or C++ sense. Call vars(obj) to see the real names. The right mental model is: single underscore says please do not touch; double underscore says you cannot accidentally touch, but you can still deliberately touch.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Does Python actually enforce private attributes with double underscores?
02
When should I use a property instead of a plain attribute in Python?
03
What is the difference between _name and __name in a Python class?
04
Is there a performance penalty for using @property?
05
How do I make a property read-only?
06
What is __slots__ and when should I use it?
🔥

That's OOP in Python. Mark it forged?

7 min read · try the examples if you haven't

Previous
Polymorphism in Python
4 / 9 · OOP in Python
Next
Magic Methods in Python