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..
20+ years shipping production Python across data and backend systems. Everything here is grounded in real deployments.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- 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
Encapsulation in Python isn't about locking down data with private keywords like Java or C++. It's about defining clear, stable contracts between your code's internal implementation and its public interface. Python's philosophy—"We are all consenting adults"—means you get naming conventions (single underscore for protected, double underscore for name mangling) rather than enforced access control.
The real value is preventing callers from depending on implementation details that will change, not hiding secrets. When you refactor internal state, a well-encapsulated class lets you do it without breaking every consumer.
Properties are the workhorse of Python encapsulation. They let you start with a simple attribute and later add validation, caching, or computed values without changing the public API. This is critical for production systems: you can ship a ConfigManager class with plain self.port attributes, then later enforce port ranges or load from environment variables—all without touching the callers.
The @property decorator transforms attribute access into method calls, giving you the ergonomics of direct access with the control of getters/setters.
Where encapsulation really earns its keep is in inheritance hierarchies and the Tell, Don't Ask principle. Name mangling (__attr) prevents accidental attribute collisions in subclasses—a real problem in large frameworks like Django or SQLAlchemy where base classes define hundreds of attributes.
Combined with properties that enforce invariants (e.g., "port must be 1024-65535"), you build components that are hard to misuse. The alternative—exposing internal state and letting callers make decisions—leads to scattered logic that's impossible to maintain.
Encapsulation forces callers to tell objects what to do, not ask for their guts and decide themselves.
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.
Encapsulation in Python Is About Contracts, Not Hiding Data
Encapsulation is the practice of bundling data with the methods that operate on that data, restricting direct access to an object's internal state. In Python, this is achieved through naming conventions (single underscore _ for protected, double underscore __ for name-mangled private) and, more robustly, through properties that allow controlled access via getters, setters, and deleters. The core mechanic is not enforced privacy — Python trusts developers — but a clear interface that separates what an object exposes from how it works internally.
What matters in practice: Python's @property decorator lets you start with simple attribute access and later add validation, caching, or computed values without changing the public API. Name mangling (__attr) avoids accidental overrides in subclasses but is still accessible via _ClassName__attr. The real power is not hiding data but defining a stable contract — consumers interact with a consistent interface while internal implementation can evolve. This reduces coupling and makes refactoring safer.
Use encapsulation when you need to enforce invariants (e.g., a BankAccount balance must never go negative), when internal state changes should trigger side effects (logging, validation), or when you want to prevent external code from breaking your object's assumptions. In production systems, it's the difference between a class that survives refactors and one that silently corrupts state because some caller directly mutated a list you thought was read-only.
_ClassName__attr. Encapsulation in Python is a convention, enforced by team discipline, not the interpreter.@property to add validation or computed logic without breaking callers.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.
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)
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.
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}")
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.
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}")
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.
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))
vars() and dir() together reveal everything.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.
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}")
__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.
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}")
Why You Actually Need Encapsulation — A Production Postmortem
You don't encapsulate because a textbook told you to. You do it because six developers touching the same class at 2 AM will corrupt your data faster than you can say 'hotfix'. Encapsulation stops that. Its job is twofold: protect data from accidental mutation, and decouple interface from implementation. Without it, one rogue line like config.timeout = -1 in some obscure module brings down your entire service. Getters and setters (or better, @property) are your guardrails. They let you add validation, logging, or even swap out the storage backend later without rewriting every caller. Encapsulation isn't hiding—it's insulating. A bank account doesn't expose its cash drawer; it exposes and deposit(). Your classes should work the same way. If you can't change internal logic without breaking 50 call sites, you've already failed.withdraw()
# io.thecodeforge class BadConfig: def __init__(self): self.timeout = 30 # anyone can set -1 class SafeConfig: def __init__(self): self._timeout = 30 @property def timeout(self): return self._timeout @timeout.setter def timeout(self, value): if value <= 0: raise ValueError("timeout must be positive") self._timeout = value cfg = SafeConfig() cfg.timeout = -1 # raises ValueError, saves your app
Protected and Private Members — Python's Convention Over Enforcement
Python doesn't have real private members. It has conventions and name mangling. Here's the truth: a single underscore (_protected) means 'please don't touch this unless you really know what you're doing'. It's a handshake agreement with other devs. Double underscore (__private) triggers name mangling to _ClassName__private, which makes accidental override less likely in inheritance. That's it—no runtime enforcement, no private keyword. Competitors will tell you __salary is 'hidden' and show an error because they typed __salary instead of _Employee__salary. Don't fall for the magic. Use _ for internal implementation details. Use __ only when you're writing a base class that subclasses might collide with (e.g., framework code). If you need real privacy in Python, use a closure or a module-level function. The language trusts you. Don't abuse that trust.
# io.thecodeforge class PaymentProcessor: def __init__(self, api_key): self.public_name = "Payment Gateway" # public self._internal_id = "proc-01" # protected: internal use only self.__api_key = api_key # private: avoid subclass collisions def _validate_payload(self, data): # internal helper, not for external callers pass p = PaymentProcessor("sk-secret") print(p.public_name) # fine print(p._internal_id) # works, but you're breaking convention print(p._PaymentProcessor__api_key) # works—Python doesn't stop you print(p.__api_key) # AttributeError
Base and Child defining __config will become _Base__config and _Child__config. No collision, no bug.Getters and Setters the Pythonic Way — Properties Over Methods
Java devs coming to Python love writing and get_name()set_name(value) methods. Stop. That's not Python. Use @property instead. It lets you start with a simple attribute and upgrade to validation later without changing the interface. That's the whole point: encapsulation without API breakage. Here's the pattern: expose self.name in version 1. In version 2, notice you need to validate the name length. Wrap it with @property and a setter. Every existing obj.name = 'foo' still works. No refactoring, no deprecation warnings, no angry users. This is why Python's property decorator exists. Getter/setter methods are verbose and ugly. Properties are clean, testable, and maintainable. If you find yourself writing obj.set_balance(, you've already lost. Write obj.get_balance() + 100)obj.balance += 100 and let the property enforce the rules.
# io.thecodeforge class AccountV1: def __init__(self, balance): self.balance = balance # plain attribute class AccountV2: def __init__(self, balance): self._balance = balance @property def balance(self): return self._balance @balance.setter def balance(self, value): if value < 0: raise ValueError("overdraft not allowed") self._balance = value acc = AccountV1(100) acc.balance = -50 # corrupts data silently acc2 = AccountV2(100) acc2.balance = -50 # ValueError: overdraft not allowed
The Silent Data Corruption That Traced Back to a Missing Property Setter
- 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.
grep -n 'self\.age\s*=' user_profile.pypython -c 'import inspect; print(inspect.getsource(obj.__class__.age.fset))'python -c 'print(vars(obj))'python -c 'print([a for a in dir(obj) if not a.startswith("__")])'vars() to access the value directly for inspection only. Never rely on mangled names in production code.type(obj).age.fgetpython -c 'print(obj.__dict__)'python -c "print(vars(obj)); print(type(obj).__mro__)"python -c "print([attr for attr in dir(obj) if 'count' in attr])"| Aspect | Public (`name`) | Protected (`_name`) | Private (`__name`) | @property | __slots__ |
|---|---|---|---|---|---|
| Access from outside class | Fully intended | Works but discouraged by convention | Raises AttributeError on direct access; accessible via _ClassName__name | Via property syntax (obj.name) — controlled by getter | Normal attribute access if declared; AttributeError if not in slots |
| Access from subclass | Yes | Yes — the intended use case | Via mangled name _ClassName__name only | Inherited unless overridden | Inherited slots are accessible; new slots must be declared in subclass |
| Name mangling applied | No | No | Yes — renamed to _ClassName__name | No — property is a class descriptor | No — slot names are stored as-is |
| Validation possible | No | No | No | Yes — via setter | No — combine with property for validation |
| Memory overhead | Normal (__dict__) | Normal (__dict__) | Normal (__dict__) | Normal (__dict__) for backing field | 40-50% lower — no per-instance __dict__ |
| Prevents accidental attribute creation | No | No | No | No | Yes — AttributeError on unknown attribute |
| Use case | Deliberate public API | Internal detail for subclasses | Collision-proofing in deep hierarchies | Validation, computed values, read-only enforcement | Memory-efficient classes with fixed attribute sets |
| File | Command / Code | Purpose |
|---|---|---|
| visibility_levels.py | class BankAccount: | Python's Three Levels of Visibility |
| user_profile_property.py | class UserProfile: | Properties |
| app_config.py | class AppConfig: | A Real-World Pattern |
| inheritance_mangling.py | class AuditedEntity: | Encapsulation With Inheritance |
| tell_dont_ask.py | class BankAccount: | Encapsulation and the Tell, Don't Ask Principle |
| slots_encapsulation.py | class ProductWithDict: | __slots__ |
| broken_vs_safe.py | class BadConfig: | Why You Actually Need Encapsulation |
| access_levels.py | class PaymentProcessor: | Protected and Private Members |
| property_upgrade.py | class AccountV1: | Getters and Setters the Pythonic Way |
Key takeaways
Common mistakes to avoid
6 patternsCreating a property backing field with the same name as the property
Trying to use __dunder__ names for private attributes
Over-engineering by wrapping every attribute in a property from day one
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.Assuming name mangling provides security or true privacy
Forgetting that @property is bypassed by direct __dict__ assignment
Declaring __slots__ without accounting for the private backing field name
Interview Questions on This Topic
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?
What happens if you write self.age = value inside an @age.setter, and how do you fix it?
If you start a class with a plain public attribute user.age and later need to add validation, how do you do it in Python without breaking all the callers who are already using user.age = value?
What is the difference between a _protected attribute and a __private attribute in a class hierarchy — and can you give a concrete scenario where using __private prevents a real bug that _protected would not catch?
How does the Tell, Don't Ask principle relate to encapsulation? Provide a code example of the bad pattern and the fix.
Frequently Asked Questions
No. Double underscores trigger name mangling — Python renames self.__value to self._ClassName__value internally. You can still access it by that mangled name from anywhere. The goal is preventing accidental collisions in inheritance hierarchies, not true data hiding. There is no equivalent of Java's private keyword in Python.
Start with a plain public attribute. Only introduce a @property when you have a concrete need: input validation, a computed or derived value, lazy loading, or making an attribute read-only. Python's @property is a zero-breaking-change refactor — callers using obj.attribute syntax never notice the difference, so there is no reason to add properties defensively upfront.
Single underscore (_name) is a pure convention — a social contract that says this is an internal implementation detail, do not use it from outside. Python will not stop anyone. Double underscore (__name) actually changes the attribute's stored name via name mangling, making it class-specific. Use single underscore for things subclasses might legitimately need; use double underscore when you specifically want to prevent a subclass from accidentally shadowing an internal attribute.
Yes, a very small one. A property getter or setter is a method call under the hood — roughly 50ns overhead per access on CPython 3.12 on modern hardware. For typical usage this is negligible. If you suspect a property is a hot path, measure it with timeit before optimising. Only avoid properties in tight inner loops that run millions of times per second where that overhead accumulates.
Define only a getter using @property and omit the setter. Attempting to assign to it raises AttributeError automatically. In Python 3.11 and later, the error message is specific: property 'name' of 'ClassName' object has no setter. In Python 3.10 and earlier, you see the less specific can't set attribute. If you want a custom error message, define a setter that raises AttributeError with your own text.
__slots__ replaces the per-instance __dict__ with a fixed set of named slots, reducing memory by 40 to 50 percent per instance and preventing accidental arbitrary attribute assignment. Use it 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 added to instances at runtime. It works naturally alongside @property — slot the private backing field, not the property name itself.
20+ years shipping production Python across data and backend systems. Everything here is grounded in real deployments.
That's OOP in Python. Mark it forged?
10 min read · try the examples if you haven't