
I still see people treating Python "underscores" like they're access modifiers from Java or C#. This always ends in confusing errors and code that feels like it should've been private - but isn't.
Here's the deal: Python doesn't enforce access control. There's no real private, protected, or public barrier. Instead, underscores are naming signals - with one special behavior for double underscores that trips up almost everyone.
| Pattern | Meaning | Enforced? | Use Case | Runtime Behavior | |
|---|---|---|---|---|---|
| Public API | name | Stable interface for users | No (by convention) | Library functions, class methods | Fully accessible |
| Internal Use | _name | Implementation details, helpers | No | Helper methods, internal state | Accessible, linter warns |
| Name Mangling | __name | Prevents subclass name collisions | Yes (name change) | Avoid subclass collisions | Renamed to _ClassName__name |
| Magic Methods | __name__ | Protocol implementation | N/A (special) | __init__, __str__, __len__ | Called by Python internals |
This is your contract with users. Changing it is a breaking change.
class User:
def get_name(self) -> str: # ✅ Public: safe to use
return self.name
def calculate_age(self) -> int: # ✅ Public: part of API
return 2024 - self.birth_year
A gentleman's agreement that this is for internal use. Python won't stop you, but linters and IDEs will warn you.
class User:
def _validate_email(self) -> bool: # 🔒 Internal: don't call directly
return "@" in self.email
def _cache_key(self) -> str: # 🔒 Internal: implementation detail
return f"user:{self.id}"
import * by defaultWhat does a single underscore prefix (`_name`) actually enforce at runtime?
This actually changes the name at runtime to prevent accidental collisions in inheritance - not to enforce privacy.
class Base:
def __secret(self) -> str:
return "base secret"
def call_secret(self) -> str:
return self.__secret() # Works! Calls Base.__secret
class Derived(Base):
def __secret(self) -> str: # Different name: _Derived__secret
return "derived secret"
def call_both(self) -> tuple:
return (self.__secret(), super().call_secret())
# Returns: ("derived secret", "base secret")
You can still access it: obj._Base__secret() works perfectly fine. It's just harder to do by accident.
What happens when a subclass defines `__secret` with the same name as a parent class?
class Parent:
def __secret(self): return "parent"
class Child(Parent):
def __secret(self): return "child"
c = Child()
print(c._Parent__secret()) # "parent" - still accessible!
print(c._Child__secret()) # "child"| Pattern | Meaning | Enforced? | Use Case | |
|---|---|---|---|---|
| Public API | <code>name</code> | Public API | No (by convention) | Stable interface for users |
| Internal Use | <code>_name</code> | Internal use | No | Implementation details, helpers |
| Name Mangling | <code>__name</code> | Name mangling | Yes (name change) | Avoid subclass collisions |
| Magic Methods | <code>__name__</code> | Dunder/magic methods | N/A | Protocol implementation |
Name Mangling
Name Mangling
Single Underscore
Single Underscore
Magic Methods
Magic Methods
class Demo:
def __init__(self):
self.public = "public"
self._internal = "internal"
self.__private = "private"
d = Demo()
# These all work:
print(d.public) # "public"
print(d._internal) # "internal" (with linter warning)
print(d._Demo__private) # "private" - name mangled!
# This fails:
# print(d.__private) # AttributeError!
Run this in a Python REPL to see the mangled names: dir(d) will show _Demo__private.
class Parent:
def __init__(self):
self.__value = 42
def get_value(self):
return self.__value
class Child(Parent):
def __init__(self):
super().__init__()
self.__value = 100 # Creates _Child__value, separate!
def get_both(self):
return (self.__value, super().get_value())
# Returns (100, 42) - two different variables!
c = Child()
print(c.get_both()) # (100, 42)
print(c._Parent__value) # 42 - still accessible!
print(c._Child__value) # 100
Only use __name when you specifically need to avoid name collisions in a class hierarchy. For "private" code, single underscore is the right choice.
Use single underscore for methods subclasses should override:
class DatabaseConnection:
def connect(self) -> Connection:
"""Public API: establish connection."""
self._validate_config()
conn = self._create_connection()
self._post_connect_setup(conn)
return conn
# Override these in subclasses:
def _validate_config(self) -> None:
"""Check required config exists."""
if not self.host:
raise ValueError("Host required")
def _create_connection(self) -> Connection:
"""Actual connection logic - override for different DBs."""
return pg_connect(self.host, self.user, self.password)
def _post_connect_setup(self, conn: Connection) -> None:
"""Hook for subclasses to run after connect."""
conn.set_timezone("UTC")
Subclasses override _create_connection for MySQL, MongoDB, etc. without touching the public connect() method. The single underscore says "override me" without enforcing it.
Why use single underscore (`_create_connection`) instead of double underscore for methods subclasses should override?
Double underscore in __slots__ is a special case - it's a class-level declaration, not name mangling:
class Point:
__slots__ = ('x', 'y', '_cached_hash') # No __dict__ created!
def __init__(self, x: float, y: float):
self.x = x
self.y = y
self._cached_hash = None # Internal cache
def __hash__(self) -> int:
if self._cached_hash is None:
self._cached_hash = hash((self.x, self.y))
return self._cached_hash
# Memory efficient: no __dict__ per instance
p = Point(1, 2)
# p.z = 3 # AttributeError - can't add arbitrary attributes
slots saves ~40-50% memory per instance by avoiding dict. Use for classes with many instances (dataclasses, game entities, data rows).
If you test _validate_email() directly, refactoring it breaks tests even if behavior is correct.
import pytest
from myapp import UserService
class TestUserService:
def test_create_user_validates_email(self):
# Test THROUGH public API, not _validate_email directly
service = UserService()
with pytest.raises(ValueError, match="Invalid email"):
service.create_user(email="not-an-email")
# Valid email works
user = service.create_user(email="valid@example.com")
assert user.email == "valid@example.com"
_validate_email → _check_email_format freelyIn these cases, import the module and test directly: from mymodule import _internal_helper
What's the main problem with testing private methods directly?
Enforced access modifiers
private - truly inaccessibleprotected - subclass onlypublic - anyoneConvention-based
name - public API_name - internal (warning only)__name - name mangledModule-based privacy
pub - publicpub(crate) - crate public| ✅ Do | ❌ Don't | |
|---|---|---|
| Naming | Use `_name` for internal helpers | Use `__name` for "privacy" |
| Documentation | Document public API with docstrings | Rely on underscores for security |
| Testing | Test through public methods | Test private methods directly |
| Optimization | Use `__slots__` for memory optimization | Add `__slots__` without profiling first |
| Respect | Respect `_name` in other's code | Access `obj._internal` in production |
Python trusts you. Underscores are communication tools, not walls.
name → "Use this freely"_name → "Internal detail, may change"__name → "Won't clash with subclass names"name → "Magic method for Python protocols"Which underscore pattern should you use for a helper method that subclasses are expected to override?
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Master foundational Python paradigms: memory references, mutable vs immutable types, scoping rules, generators, and clean object-oriented patterns.
Read more
A practical collection of Python techniques that reduce boilerplate, prevent bugs, and make everyday code cleaner - selected from real codebases, not tutorial fantasy.
Read more
I burned too many weekends on the wrong Python libraries so you don't have to. Here's what I actually use to ship things that stay working.
Read more