Python's Underscores: Conventions, Not Access Modifiers

Table of Contents
Common Misconception
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.
The Three Levels of "Privacy"
| 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 |
No Underscore → Public API
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
Single Underscore `_name` → Internal Convention
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}"
What single underscore actually does
- Signals "internal use only" to other developers
- Excluded from
import *by default - Linters (ruff, pyflakes) will warn on external access
- Zero runtime enforcement — you can still call it!
Double Underscore `__name` → Name Mangling
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")
Name mangling is NOT privacy
You can still access it: obj._Base__secret() works perfectly fine. It's just harder to do by accident.
Visual Summary
| 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!
Try it yourself
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
When to actually use double underscore
Only use __name when you specifically need to avoid name collisions in a class hierarchy. For "private" code, single underscore is the right choice.
Real-World Patterns
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")
Why this works
Subclasses override _create_connection for MySQL, MongoDB, etc. without touching the public connect() method. The single underscore says "override me" without enforcing it.
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
Performance note
slots saves ~40-50% memory per instance by avoiding dict. Use for classes with many instances (dataclasses, game entities, data rows).
Testing `_private` methods couples tests to implementation
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"
Benefits
- Tests survive refactoring of internal implementation
- Tests document expected behavior, not internal structure
- You can change
_validate_email→_check_email_formatfreely
Rare exceptions where testing private is OK
- Complex algorithms with many edge cases (cryptography, compression)
- Legacy code you can't refactor yet
- Performance-critical paths where public API adds overhead
In these cases, import the module and test directly: from mymodule import _internal_helper
Comparison: Python vs Other Languages
Java / C#
Enforced access modifiers
private— truly inaccessibleprotected— subclass onlypublic— anyone- Compiler enforces at build time
Python
Convention-based
name— public API_name— internal (warning only)__name— name mangled- Runtime: zero enforcement
Rust
Module-based privacy
pub— public- Default — module private
pub(crate)— crate public- Compiler enforces at build time
Decision Flowchart
Best Practices Checklist
| ✅ 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 |
TL;DR
Remember
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"
You Might Also Like
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

Fork It, Keep It Private, Stay in Sync with Upstream
Keep your private fork in sync with upstream using git rebase instead of merge. A real workflow for adding custom code to an open source project without losing access to bug fixes.
Read more
The Python Fundamentals You Actually Need to Start
Master foundational Python paradigms: memory references, mutable vs immutable types, scoping rules, generators, and clean object-oriented patterns.
Read more
Python Tricks I Actually Reach For Daily
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