Encapsulation is the idea that an object should expose what it does and hide how it does it. Most languages enforce this with keywords like private and public; Python doesn't. Instead, Python uses naming conventions and one bit of compiler-level help called name mangling, and trusts you to respect the rest. This chapter covers how that works, why Python made that choice, and how to write classes that signal intent clearly even without a real privacy enforcer.
The word gets thrown around a lot, so it's worth pinning down. Encapsulation is two related things bundled into one principle: keeping the data and the code that operates on it together (that's the "capsule" part), and hiding the internal details so callers can't depend on them.
Picture a customer's bank balance on an e-commerce site. The class that owns the balance should be the one that decides how a deposit, a withdrawal, or a refund changes it. If outside code can reach in and set balance to whatever it wants, two things go wrong. You can end up with invalid states (negative balances, balances that don't match the transaction log), and the class loses the ability to evolve, because every caller is now depending on the exact shape of its internal data.
There's no error and no warning. Python lets you assign anything to any attribute. The class has effectively no control over its own state.
Encapsulation is the discipline of fixing that, even when the language won't fix it for you. The class exposes a few well-chosen methods (deposit, withdraw), and those methods are the only sanctioned way to change the data. Callers can still cheat if they really want to, but the class makes it clear what is and isn't part of the contract.
Python's philosophy on access control is famously summed up in the line "we're all consenting adults here." The language gives you tools to mark something as internal, but it doesn't physically block anyone from touching it. This is a deliberate choice, not an oversight.
Other languages put walls around private fields. The compiler refuses to compile code that reads a private attribute from outside the class. Python instead puts up signs. A leading underscore is the sign for "this is internal, please don't touch it from outside." Two leading underscores is the sign for "this is really internal, and I want a small mechanical barrier to prevent accidents in subclasses." Neither sign stops a determined caller. Both signs make the intent obvious.
The upside of this approach is that it keeps the language simple and lets library authors evolve their code without forcing users into ceremony. The downside is that it puts responsibility on the developer to follow the conventions. Most Python code does, because the community treats the conventions as binding even though the interpreter doesn't.
The diagram lays out the three levels Python recognizes. Public names are part of the class's contract. Protected names are an internal layer that subclasses may need to see but external callers shouldn't. Private names are reserved for the class itself, with a small mechanical guard to keep subclasses from clobbering them by accident.
If a name doesn't start with an underscore, it's public. Public attributes are the part of the object that the rest of your program is allowed to use directly. They're the contract.
Both name and price are public. Any code that has a Product instance is allowed to read or write them, and the class is committing to keep them around. If a future version of Product removes price or renames it to unit_price, every caller breaks. That's the price of marking something public: you're promising it'll stay there.
For a lot of small classes, plain public attributes are exactly what you want. Not every value needs to be locked down. A coordinate, a color, a configuration value, these are fine as bare public attributes. The discipline of encapsulation isn't "hide everything"; it's "hide what shouldn't be touched, expose what should."
_protectedA name that starts with one underscore is a convention that means "this is internal, treat it as part of the implementation, not the API." Python itself does nothing with the underscore. The interpreter doesn't warn you, doesn't hide the name, doesn't slow you down when you access it. The convention is purely social.
The _balance attribute is internal. The class is saying: I might rename this, I might split it into two attributes, I might back it with a database lookup tomorrow. Don't reach for it directly. Use the methods.
Now here's the thing the convention can't stop:
That worked. Python didn't blink. You broke the rule, you got a corrupted object, and nobody warned you. The single underscore is a sign on a door, not a lock. If you ignore the sign and the next library release renames _balance to _current_balance_cents, your code breaks and the library author isn't to blame.
The single underscore is also the convention for "I'm using this name but not really". You'll see it in unpacking when a value is being discarded:
That use is unrelated to encapsulation. It's the same underscore character, but here it's just a throwaway name for the email we're not using. Context tells you which meaning is in play.
__private and Name ManglingA name that starts with two underscores (and doesn't end with two) triggers Python's name mangling. Inside the class body, you write self.__internal_id. Behind the scenes, Python rewrites that to self._ClassName__internal_id everywhere in the class.
So far it looks like any other attribute. Try to access it from outside:
That's the name mangling at work. The name you wrote (__internal_id) doesn't actually exist on the instance. What exists is the mangled version:
Python rewrote __internal_id to _Product__internal_id. Inside the class body, the rewrite happens automatically, so the code reads naturally. From outside the class, you'd have to know the mangling rule and type the full mangled name, which makes accidental access much less likely.
The diagram traces what happens to __internal_id. Python sees the double-underscore prefix while it's compiling the class body and rewrites every reference inside that body to _Product__internal_id. The attribute on the instance is stored under the mangled name. From outside the class, the original name doesn't resolve, but the mangled name does, so the door isn't locked, it's just relocated.
A few details worth knowing about the rule:
__init__ and __str__ (double underscores on both sides) are dunders and are not mangled.Product has a method that references self.__internal_id, that reference always rewrites to _Product__internal_id, even when called on a subclass instance.Name mangling exists for one main reason, and it's not "to make attributes private." It's to keep base classes and subclasses from accidentally clobbering each other's internal attributes when they happen to pick the same name.
Imagine a base class Product uses self.__id to store something internal. A subclass SubscriptionProduct is written by someone else who also uses self.__id for their own internal bookkeeping. Without mangling, the two would share the same attribute, and one would silently overwrite the other.
Both classes define self.__id, but the mangling rewrites them to different names. Product.show_id looks up _Product__id, and SubscriptionProduct.show_sub_id looks up _SubscriptionProduct__id. They don't collide. You can confirm by inspecting the instance:
Two separate attributes, one on each "layer" of the object. If Python didn't mangle, the SubscriptionProduct.__init__ line would overwrite the value the parent class set, and show_id would return the wrong thing. The whole point of mangling is to make this kind of accident impossible without you opting in by typing the mangled name out by hand.
That framing matters: double underscores are a tool to avoid name clashes in inheritance hierarchies. They're not a security feature. If you mostly write classes that no one subclasses, the single-underscore convention is the usual choice. Reach for double underscores when you're writing a base class meant to be subclassed and you have internal state that no subclass should touch.
When you want callers to read or change an internal attribute through controlled methods, the simplest tool is a regular method. Name it get_x to read and set_x to write.
The class controls the data. The setter rejects a negative price and protects the object from an invalid state. The caller has no good reason to reach for _price directly because the methods cover both read and write.
You can do the same for the bank-account-style Customer:
This pattern works and it's familiar to anyone who's written Java or C# code. The catch is that Python has a cleaner way to express the same thing. Decorating a method with @property lets cable.price look like a plain attribute read while running a method behind the scenes, and a matching @price.setter lets cable.price = 11.99 run a validation method behind the scenes. The caller doesn't need to know whether price is a raw attribute or a computed property; the syntax is the same.
The takeaway:
get_x / set_x methods work fine and are easy to read.@property when the value should look like an attribute from the outside.product.price) doesn't change.The three styles look nearly identical at a glance, but they signal different things. This table captures the differences in one place:
| Style | Example | Python enforces? | Intent | When to use |
|---|---|---|---|---|
| Public | name | No (and public) | Part of the API. Use freely. | Plain values that callers are allowed to read and write directly. |
| Protected | _balance | No, convention | Internal. Don't touch from outside. | Implementation detail. Subclasses may still use it. |
| Private | __internal_id | Name mangling | Internal, and protected from clashes. | Base classes that don't want subclasses overwriting their internal state. |
| Dunder | __init__ | Not mangled | Reserved for Python's protocols. | Never invent your own dunder names. They belong to the language. |
Two things to flag from the table. First, "Python enforces" is "no" for both public and protected; the difference is intent, not mechanics. Second, the dunder row is included because it's easy to mistake __init__ for a "private" name. Names that have two leading and two trailing underscores are not mangled, they're how Python's protocols hook into your classes, and you should leave that naming style to the language.
When you're working with name mangling, the fastest way to see what's actually stored on an instance is to inspect it with vars() or dir(). Both will show the mangled forms.
Three attributes, but the names tell different stories. name is the public contract. _balance is the protected convention, stored under its written name. __internal_id is gone from the dictionary; the actual key is _Customer__internal_id because Python mangled it. The vars() builtin returns the instance's __dict__, which is the literal mapping that backs attribute lookups.
dir() shows even more, including the methods inherited from the class and the dunder bookkeeping:
After filtering out underscore-prefixed names, only name survives. That filter is a quick way to see the public surface of an object. The mangled _Customer__internal_id is excluded because it starts with an underscore, and so are _balance and all the dunders.
This visibility is the other side of "consenting adults." Nothing is truly hidden; you can always look. But the structure of the names tells you what the class intended.
10 quizzes