I Thought Private Variables Were Actually Private (Then I Accessed Them From Outside the Class)
Last Updated on January 2, 2026 by Editorial Team
Author(s): Dua Asif
Originally published on Towards AI.
class BankAccount:
def __init__(self, balance):
self.__balance = balance # Private variable
def get_balance(self):
return self.__balance
def withdraw(self, amount):
if amount <= self.__balance:
self.__balance -= amount
return True
return False
Two underscores. That makes it private. Right? Like private in Java or C++. Nobody can access __balance from outside the class.
I was confident. This was secure. The balance was protected.
Then a colleague showed me this…..
account = BankAccount(1000)
# Try to access private variable
print(account.__balance) # AttributeError: 'BankAccount' object has no attribute '__balance'
# Good, it's private... or is it?
print(account._BankAccount__balance) # 1000
# Wait, what?
account._BankAccount__balance = 999999
print(account.get_balance()) # 999999
I could access the “private” variable. I could modify it. All my security was an illusion.
That day….. staring at code that broke every assumption I had about encapsulation….. I learned the most surprising truth about Python.
There are no private variables. Everything is accessible. Everything can be modified. And this is by design.
Let me show you why Python’s “privacy” is completely different from what I thought it was.
The Double Underscore That Isn’t Really Private
I thought __variable made things private. It doesn't.
class MyClass:
def __init__(self):
self.__private = "secret"
self._protected = "careful"
self.public = "everyone"
What actually happens…..
obj = MyClass()
# Can't access with original name
obj.__private # AttributeError
# But Python just renamed it
obj._MyClass__private # "secret"
# This is called name mangling
# Python adds _ClassName to the front
Name mangling is not security. It’s just making it harder to accidentally override variables in subclasses.
class Parent:
def __init__(self):
self.__value = "parent"
def show(self):
print(self.__value)
class Child(Parent):
def __init__(self):
super().__init__()
self.__value = "child" # Different variable due to mangling
def show_child(self):
print(self.__value)
obj = Child()
obj.show() # "parent" - Parent's __value
obj.show_child() # "child" - Child's __value
# Two different variables
print(obj._Parent__value) # "parent"
print(obj._Child__value) # "child"
The double underscore prevents naming conflicts. Not access.
The Single Underscore That’s Just a Convention
Python also has single underscore.
class MyClass:
def __init__(self):
self._internal = "not really private"
I thought this meant “protected” like in Java. Only accessible to the class and subclasses.
Wrong. It’s just a convention. A hint to other developers.
class MyClass:
def __init__(self):
self._internal = "internal use"
def _helper_method(self):
return "also internal"
obj = MyClass()
# Nothing stops you from accessing these
print(obj._internal) # "internal use"
print(obj._helper_method()) # "also internal"
# Completely accessible
obj._internal = "modified"
print(obj._internal) # "modified"
The single underscore means “I’m telling you this is internal implementation. Use at your own risk.”
But Python won’t stop you. It’s a social contract, not a technical restriction.
# From other developers' perspective
import requests
# Public API
response = requests.get('https://api.example.com')
# Internal implementation (but still accessible)
session = requests.Session()
adapter = session._adapter # Accessing "internal" attribute
# This might break in future versions
# But Python lets you do it
The Property That Gave Me False Security
I thought I could secure my class with properties.
class BankAccount:
def __init__(self, balance):
self.__balance = balance
@property
def balance(self):
"""Read-only balance."""
return self.__balance
def withdraw(self, amount):
if amount <= self.__balance:
self.__balance -= amount
account = BankAccount(1000)
# Can read balance
print(account.balance) # 1000
# Can't set it directly
account.balance = 999999 # AttributeError: can't set attribute
Perfect! The balance is read-only. Secure.
Except…..
# Access the mangled name directly
account._BankAccount__balance = 999999
print(account.balance) # 999999
# Property protection bypassed
Properties control the public interface. They don’t protect the underlying data.
class Temperature:
def __init__(self, celsius):
self.__celsius = celsius
@property
def celsius(self):
return self.__celsius
@property
def fahrenheit(self):
return self.__celsius * 9/5 + 32
@fahrenheit.setter
def fahrenheit(self, value):
self.__celsius = (value - 32) * 5/9
temp = Temperature(25)
print(temp.celsius) # 25
print(temp.fahrenheit) # 77.0
# Set through fahrenheit
temp.fahrenheit = 86
print(temp.celsius) # 30.0
# Or bypass it entirely
temp._Temperature__celsius = 100
print(temp.celsius) # 100
print(temp.fahrenheit) # 212.0
Properties are convenience, not security.
The Descriptor That Couldn’t Stop Me
I tried using descriptors for validation.
class PositiveNumber:
def __init__(self, name):
self.name = name
def __get__(self, obj, objtype=None):
return obj.__dict__.get(self.name, 0)
def __set__(self, obj, value):
if value < 0:
raise ValueError(f"{self.name} must be positive")
obj.__dict__[self.name] = value
class BankAccount:
balance = PositiveNumber('balance')
def __init__(self, balance):
self.balance = balance # Goes through descriptor
account = BankAccount(1000)
# Validation works
try:
account.balance = -100 # ValueError
except ValueError as e:
print(e) # "balance must be positive"
# But can bypass descriptor
account.__dict__['balance'] = -999999
print(account.balance) # -999999
Descriptors control attribute access through the normal interface. But __dict__ gives direct access to the underlying storage.
class ValidatedAttribute:
def __init__(self, name, validator):
self.name = name
self.validator = validator
self.storage_name = f'_{name}'
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.storage_name, None)
def __set__(self, obj, value):
self.validator(value)
setattr(obj, self.storage_name, value)
def positive_validator(value):
if value < 0:
raise ValueError("Must be positive")
class Account:
balance = ValidatedAttribute('balance', positive_validator)
def __init__(self, balance):
self.balance = balance
account = Account(1000)
# Validation works
try:
account.balance = -100
except ValueError:
pass
# Can bypass through storage name
account._balance = -999999
print(account.balance) # -999999
# Or through __dict__
account.__dict__['_balance'] = -888888
print(account.balance) # -888888
Everything is accessible. Always.
The Slot That Almost Worked
I discovered __slots__. It restricts which attributes can exist.
class RestrictedClass:
__slots__ = ['name', 'value']
def __init__(self, name, value):
self.name = name
self.value = value
obj = RestrictedClass("test", 100)
# Can't add new attributes
try:
obj.new_attr = "test" # AttributeError
except AttributeError:
pass
# Can't access __dict__ (it doesn't exist)
try:
print(obj.__dict__) # AttributeError
except AttributeError:
pass
Finally! A way to restrict access!
Except…..
# But slots are still accessible
print(obj.__slots__) # ['name', 'value']
# And you can still modify existing attributes
obj.name = "modified"
obj.value = -999999
# And if you have a reference to the slots...
# You can get/set values
getattr(obj, 'value') # -999999
setattr(obj, 'value', 0)
Slots prevent adding new attributes. They don’t prevent modifying existing ones.
And with inheritance…..
class Base:
__slots__ = ['x']
class Derived(Base):
# If derived doesn't define __slots__, it gets __dict__
pass
obj = Derived()
obj.x = 1
obj.y = 2 # Works! Has __dict__ because Derived doesn't define __slots__
print(obj.__dict__) # {'y': 2}
Slots are for memory optimization, not security.
Why Python Is This Way
After discovering that everything in Python is accessible….. I was frustrated. Why have __private if it doesn't actually make things private?
Then I learned the philosophy….. “We’re all consenting adults here.”
Python trusts developers. If you want to access internal implementation….. you can. But you’re responsible for the consequences.
# This is Python's philosophy
class MyClass:
def __init__(self):
self._internal = "use carefully" # Single underscore: hint
self.__private = "name mangled" # Double: avoid conflicts
def public_method(self):
"""Public API."""
pass
def _internal_method(self):
"""Internal implementation."""
pass
The underscores say…..
- No underscore: “Public API. Use freely. Won’t change without deprecation.”
- Single underscore: “Internal implementation. Might change. Use at own risk.”
- Double underscore: “Really internal. Avoiding name conflicts in inheritance.”
But all are accessible because…..
Use Case 1: Testing
class PaymentProcessor:
def __init__(self):
self._api_client = SomeAPIClient()
def process_payment(self, amount):
# Complex logic
response = self._api_client.charge(amount)
return response
# In tests, I need to mock the internal client
def test_payment():
processor = PaymentProcessor()
processor._api_client = MockAPIClient() # Access "internal" attribute
result = processor.process_payment(100)
assert result.success
If _api_client was truly private….. testing would be harder.
Use Case 2: Debugging
# Production bug
obj = SomeComplexClass()
# Something is wrong, need to inspect internal state
print(obj._internal_cache) # Look at internal details
print(obj._state_machine) # Understand what's happening
# Fix the bug based on internal state
If internals were truly private….. debugging production issues would be impossible.
Use Case 3: Extending Libraries
# Third-party library
import some_library
class MyExtension(some_library.BaseClass):
def enhanced_method(self):
# Need to access parent's internal implementation
data = self._internal_data_structure
# Do something the library didn't anticipate
processed = my_custom_logic(data)
return processed
If libraries locked down internals….. extending them would be impossible.
The Real Way to Protect Data in Python
If you truly need security….. don’t rely on Python’s access control. Use actual security measures.
Option 1: Use a separate service
# Don't store sensitive data in Python objects
class BankAccount:
def __init__(self, account_id):
self.account_id = account_id
def get_balance(self):
# Query secure database
return database.query_balance(self.account_id)
def withdraw(self, amount):
# Secure transaction in database
return database.execute_withdrawal(self.account_id, amount)
# Balance never stored in Python object
# Always retrieved from secure source
Option 2: Encryption
from cryptography.fernet import Fernet
class SecureData:
def __init__(self, key):
self._cipher = Fernet(key)
self._encrypted_data = None
def set_data(self, data):
self._encrypted_data = self._cipher.encrypt(data.encode())
def get_data(self):
if self._encrypted_data:
return self._cipher.decrypt(self._encrypted_data).decode()
return None
# Even if someone accesses _encrypted_data
# They can't read it without the key
Option 3: Immutable data structures
from typing import NamedTuple
class BankAccount(NamedTuple):
account_id: str
balance: float
def withdraw(self, amount):
if amount <= self.balance:
# Return new object instead of modifying
return BankAccount(self.account_id, self.balance - amount)
return self
# Can't modify the object
account = BankAccount("123", 1000)
# account.balance = 999 # AttributeError
# Can only create new objects
new_account = account.withdraw(100)
print(account.balance) # 1000 (original unchanged)
print(new_account.balance) # 900 (new object)
Option 4: Validation on every access
class ValidatedAccount:
def __init__(self, balance):
self._balance = balance
self._validate()
def _validate(self):
"""Check data integrity."""
if not isinstance(self._balance, (int, float)):
raise ValueError("Balance must be numeric")
if self._balance < 0:
raise ValueError("Balance cannot be negative")
@property
def balance(self):
self._validate() # Validate before returning
return self._balance
def withdraw(self, amount):
self._validate() # Validate current state
if amount <= self._balance:
self._balance -= amount
self._validate() # Validate after modification
# If someone modifies _balance directly
account = ValidatedAccount(1000)
account._balance = -999 # Bypasses setter
# Next access triggers validation
try:
print(account.balance) # ValueError: Balance cannot be negative
except ValueError:
# Invalid state detected
pass
What I Learned About Python’s Design
After discovering that Python has no real privacy….. I was initially frustrated. Coming from Java, I expected private to mean private.
But Python’s approach makes sense for its use cases…..
Python is about rapid development and flexibility. Strict encapsulation slows you down. Python trusts you to make good decisions.
Python is used heavily in data science and scripting. You often need to inspect and modify internal state for debugging and exploration.
Python has a strong culture of open source. If you need to extend a library in ways the author didn’t anticipate….. you can access internals.
The tradeoffs…..
Java/C++ approach:
- Strict enforcement
- Compile-time checking
- Clear public/private boundaries
- Harder to debug
- Harder to extend
- More verbose
Python approach:
- Trust developers
- Runtime flexibility
- Everything accessible
- Easier to debug
- Easier to extend
- More concise
Neither is better. They optimize for different things. Java optimizes for large teams and long-term maintenance with strict contracts. Python optimizes for rapid development and flexibility.
The key insight….. privacy in Python is about communication, not enforcement.
# This says: "I don't expect you to use this"
self._internal
# This says: "Avoid name conflicts in inheritance"
self.__private
# This says: "Public API, use freely"
self.public
You’re free to ignore these hints. But then you’re responsible when internal implementation changes break your code.
What language feature surprised you when you learned how it actually works? Share it below….. we’ve all had assumptions shattered by reality.
Join thousands of data leaders on the AI newsletter. Join over 80,000 subscribers and keep up to date with the latest developments in AI. From research to projects and ideas. If you are building an AI startup, an AI-related product, or a service, we invite you to consider becoming a sponsor.
Published via Towards AI
Towards AI Academy
We Build Enterprise-Grade AI. We'll Teach You to Master It Too.
15 engineers. 100,000+ students. Towards AI Academy teaches what actually survives production.
Start free — no commitment:
→ 6-Day Agentic AI Engineering Email Guide — one practical lesson per day
→ Agents Architecture Cheatsheet — 3 years of architecture decisions in 6 pages
Our courses:
→ AI Engineering Certification — 90+ lessons from project selection to deployed product. The most comprehensive practical LLM course out there.
→ Agent Engineering Course — Hands on with production agent architectures, memory, routing, and eval frameworks — built from real enterprise engagements.
→ AI for Work — Understand, evaluate, and apply AI for complex work tasks.
Note: Article content contains the views of the contributing authors and not Towards AI.