Most object-oriented languages put real walls around class attributes. The compiler refuses to read a private field from outside its class, and the public/private split is enforced at the language level. Python takes a different route. It signals visibility through naming conventions, with one small mechanical helper called name mangling, and trusts you to follow the signs. This chapter pins down what "public", "protected", and "private" mean in Python, what the language actually enforces, and how to use these conventions to design clear APIs.
Python recognizes three visibility levels for class attributes, but only one of them involves any real compile-time work. The other two are pure naming conventions that the community treats as binding even though the interpreter doesn't.
Here are the three styles side by side:
The leading underscores are the only difference. No keyword, no decorator, no annotation. Python sees the name when the class is compiled and applies its rule to it: bare names are public, single-underscore names are a convention, double-underscore names get rewritten internally.
The diagram captures the practical effect of each prefix. Public names are part of the contract. Single-underscore names are an implementation detail the class would like you to leave alone. Double-underscore names get a compile-time rename that makes accidental access from outside the class much less likely.
If a name doesn't start with an underscore, it's public. There's no keyword to mark a name public; the absence of the underscore is the marker. Public attributes are the part of the class that the rest of the program can read and write directly. They're the contract the class is committing to keep stable.
Both name and price are public. Anyone with a Product instance can read them, write them, or delete them. The class isn't validating anything when price is reassigned; it takes whatever value is passed in. If a future version of the class renames price to unit_price, every caller breaks. That's the deal with public attributes: the class is committing to keep them around.
Not every value needs to be locked down. A coordinate, a color, a configuration setting, a product name, these often work fine as plain public attributes. The discipline of encapsulation isn't "hide everything"; it's "expose what should be exposed, hide what should be hidden." For small data-bag classes, public is the default.
There's one more way to see what's public on an instance: filter dir():
dir() returns every name on the instance and its class, including the dunders inherited from object. Filtering out names that start with an underscore gives you the public surface, the names the class is committing to keep stable.
A name that starts with one underscore is the convention for "this is internal, please don't touch from outside the class." Python itself does nothing special with the underscore at runtime. The interpreter doesn't warn, doesn't hide the name, doesn't refuse to read it. The convention is purely social, and the only enforcement is whatever the team and the tools agree to do.
The _balance attribute is marked internal. The class is saying: this name might be renamed, split into two attributes, or backed by a database lookup tomorrow. Don't access it directly. Use the methods.
This is what Python does to stop direct access:
Nothing. Python lets the assignment through, the read through, no error, no warning. The single underscore is a sign on a door, not a lock. If a caller ignores the sign and the next library release renames _balance to _current_balance_cents, their code breaks and the library author isn't to blame.
The reason this convention works is that the Python community treats it as binding. Linters flag accesses to _x from outside the owning class. Code reviewers push back on it. Editors might gray out underscore-prefixed names in autocomplete or sort them to the bottom. Documentation generators skip them when producing API docs. The discipline isn't enforced by the interpreter; it's enforced by the ecosystem.
One wildcard rule applies: from module import * skips names that start with an underscore. A module with a helper function _normalize_email won't expose it through from email_utils import *. That's the closest thing to enforcement Python ships, and it only applies to module-level names, not class attributes.
The single underscore has one other common use that has nothing to do with encapsulation. In unpacking, _ is the conventional name for a value being deliberately ignored:
That's the same underscore character, but it's not marking anything internal. It's a throwaway name. Context tells which meaning is in play.
A name that starts with two underscores (and doesn't end with two) triggers Python's name mangling. The name __balance inside a class called Customer is rewritten by the compiler to _Customer__balance everywhere inside the class body. The attribute on the instance is stored under that mangled name, and reads or writes inside the class refer to the mangled name automatically.
vars(alice) shows the literal storage dictionary on the instance. The key isn't __balance; it's _Customer__balance. Inside the Customer class body, every self.__balance was rewritten to self._Customer__balance. From the class's own methods, the code reads naturally. From outside the class, the original name no longer resolves on the instance.
That's the practical effect: the double underscore makes accidental access from outside the class fail with AttributeError. The attribute is still reachable through its mangled name (alice._Customer__balance works fine), but reaching it requires knowing the rule and typing the mangled name by hand, which makes the access deliberate rather than accidental.
This is the only one of the three visibility levels that involves a real mechanical step. It's not security; the value is still there and still readable through the mangled name. It does protect against two specific accidents: misspelling _balance as __balance when writing code outside the class (which would fail loudly), and overwriting a base class's internal attribute from a subclass that uses the same __name.
Double underscores are a tool, not a guarantee. Use them in base classes that are meant to be subclassed and have internal state that subclasses shouldn't overwrite. For ordinary internal attributes, the single-underscore convention is the usual choice.
The three styles look almost identical at a glance, so it helps to put them side by side and list exactly what Python does for each one.
| Style | Example | Stored as | External access | What Python enforces |
|---|---|---|---|---|
| Public | name | name | obj.name works | Nothing, fully accessible |
| Protected | _balance | _balance | obj._balance works | Nothing, convention only |
| Private | __id | _ClassName__id | obj.__id raises | Compile-time name rewrite |
| Dunder | __init__ | __init__ | obj.__init__ works | Not mangled |
Two rows are worth flagging. The "private" row is the only one where Python does something mechanical; the rewrite is real, and the attribute really doesn't exist under the unprefixed name. The "dunder" row is included because __init__ is often confused for a private name. Dunders have two underscores on both sides, and the trailing pair exempts them from mangling. They're reserved for Python's own protocols (__init__, __str__, __len__, and so on) and shouldn't be invented for custom use.
The other thing to flag is that "external access" only covers what happens if the name is typed as written. The mangled attribute is still reachable through the mangled name, and the single-underscore attribute is reachable through any name at all because nothing was renamed. The wall isn't there; the sign is.
Coming from a language with real access modifiers, Python's approach can look loose at first. The differences come down to where the enforcement lives and how flexible the language wants to be.
In Java, private means the compiler rejects any code outside the class that tries to read the field. The check happens at compile time, the bytecode itself encodes the access level, and reflection is the only way around it. C# and C++ are similar in spirit: access modifiers are part of the type system, the compiler enforces them, and breaking them is a deliberate, awkward act.
Python doesn't have any of that. The interpreter doesn't track access levels and doesn't refuse any attribute read. What Python has instead is naming conventions and one compile-time rewrite. The trade-off is real, and the language community has chosen the more flexible side of it on purpose:
| Concern | Java / C# / C++ | Python |
|---|---|---|
| Who enforces visibility? | The compiler | Naming conventions and tools |
| Can outside code reach in? | No (except via reflection) | Yes, always |
| Subclass collision protection | Often automatic | Opt-in via double underscore (mangling) |
| Library evolution | Adding a getter is a breaking change | Replacing an attribute with @property is invisible |
| Cost of starting public | High (refactoring callers later is painful) | Low (callers don't need to change) |
The last row is the one that drives Python's design. Because reading obj.price looks the same whether price is a raw attribute or a @property-decorated method, library authors can start with a plain public attribute and convert it to a property later without breaking any caller. In Java, the same conversion means changing every obj.price to obj.getPrice(), which is why Java codebases tend to write getters and setters from day one. Python skips the up-front cost because the future migration is free.
The other side of the trade-off is the one teams have to live with. Python won't catch misuse. If a developer writes customer._balance = -100, the code runs and the object ends up in an invalid state. The discipline has to come from the team, the linters, the code review process, and the documentation. Most Python shops accept that trade-off because the language stays simple and the conversion-to-property escape hatch is cheap.
The interesting question isn't "what does each underscore do." It's "which attributes should be public, and which should be hidden?" The answer comes from the same principle as in any language: the public surface is a contract the class is committing to keep stable. Hide what should be free to change; expose what callers need.
Some practical rules:
deposit, withdraw) rather than a raw attribute. Methods can validate; raw attributes can't.A Customer class that follows these rules might look like this:
name is public because it's a plain piece of identification with no validation. MAX_WITHDRAWAL is public because it's a stable policy value that callers might want to inspect. _balance is internal because the class wants to control every change to it. _check_positive is internal because it's a helper, not part of the API.
If a year from now the class evolves to back _balance with a database lookup, callers don't change. They were already going through get_balance and the modification methods. The internal-by-convention attribute can be reshaped freely. That's the payoff of getting the visibility split right up front.
@propertyA common need is the convenience of a public-looking attribute access (product.price) combined with the control of a method (validation, computation, lazy loading). The tool for that job is @property, which lets a method look like an attribute read from the outside while running code internally. A matching @price.setter does the same on the write side.
From the caller's side, cable.price looks like a plain attribute read. From the class's side, it's a method call that returns self._price and a setter that validates before assigning. That's the migration path Python's design enables: a class can start with a plain public price attribute, and if validation becomes necessary later, the class can convert price to a property without breaking any caller.
The relevance here is the design lesson. Python's "consenting adults" stance works in practice because converting a public attribute to a controlled property is invisible to callers. Most Python code exposes attributes directly because the cost of locking them down later is essentially zero.
10 quizzes