Skip to main content

Encapsulation and Data Hiding - Properties, Name Mangling, and Descriptors

Reading time: ~32 minutes | Level: Intermediate → Engineering

Before reading further, predict every output:

class BankAccount:
def __init__(self, balance):
self.__balance = balance

def deposit(self, amount):
self.__balance += amount

account = BankAccount(1000)
account.deposit(500)

print(account.__balance) # ?
print(account._BankAccount__balance) # ?

Most developers expect:

  • account.__balance1500
  • account._BankAccount__balanceAttributeError

The actual results are the opposite:

  • account.__balanceAttributeError: 'BankAccount' object has no attribute '__balance'
  • account._BankAccount__balance1500

The double underscore does not make an attribute private in the C++/Java sense. It does something completely different - it mangles the name. The attribute __balance defined inside class BankAccount is stored as _BankAccount__balance. The original name __balance does not exist on the object.

This is not a bug. It is a feature. And understanding it precisely - along with @property and the descriptor protocol - is the foundation of every piece of Python code that manages state safely.

What You Will Learn

  • The single underscore convention (_name) - what it means and what it does not do
  • Double underscore name mangling (__name) - the exact transformation Python applies and why
  • @property for controlled attribute access - reading attributes that behave like attributes but execute code
  • @setter and @deleter - writing and deleting with validation
  • Validation patterns in property setters
  • __slots__ revisited with inheritance implications
  • The descriptor protocol: __get__, __set__, __delete__ - what powers @property, @classmethod, and @staticmethod
  • Computed attributes and caching with descriptors

Prerequisites

  • Lesson 01: Classes and Objects - class vs instance namespace, attribute resolution chain
  • Lesson 02: __init__ and Object Construction - self, instance state
  • Lesson 03: Dunder Methods - protocol system
  • Understanding Python's attribute lookup (MRO, __dict__)

Part 1 - The Underscore Convention

Single Underscore - "Internal Use" Convention

A single leading underscore (_name) is a naming convention. It communicates to other developers: "this attribute or method is intended for internal use - I may change it without warning."

Python does not enforce this at all. The attribute is fully accessible:

class Connection:
def __init__(self, host, port):
self.host = host
self.port = port
self._socket = None # internal - implementation detail
self._retry_count = 0 # internal - subject to change

def _reconnect(self): # internal helper - not part of the public API
"""Implementation detail - callers should not call this directly."""
self._retry_count += 1
self._socket = self._open_socket()

def _open_socket(self):
pass # simplified

conn = Connection("db.example.com", 5432)
print(conn._retry_count) # 0 - accessible, just conventionally "internal"
conn._reconnect() # also accessible - the _ is a social contract, not a lock

The single underscore means: "if you access this, you are stepping outside the public API and accepting that it may break across versions." It is the Python equivalent of "friend" in C++ - a documented boundary, not a hard wall.

Where You Will See _ in the Wild

  • _data, _cache, _conn: internal implementation attributes
  • from module import * skips names starting with _ (another effect of the convention)
  • _ as a throwaway variable: for _ in range(5): or x, _ = func()

Part 2 - Double Underscore Name Mangling

What Mangling Does

When Python compiles a class body and encounters an identifier with two or more leading underscores and at most one trailing underscore, it rewrites the name by prepending _ClassName:

__name defined inside class Foo → stored as _Foo__name
__name defined inside class Bar → stored as _Bar__name

This happens at compile time, before the code runs:

class BankAccount:
def __init__(self, balance):
self.__balance = balance # stored as _BankAccount__balance

def get_balance(self):
return self.__balance # access is also mangled at compile time
# → self._BankAccount__balance

account = BankAccount(1000)
print(account.__dict__)
# {'_BankAccount__balance': 1000} - note the mangled key

print(account._BankAccount__balance) # 1500 - accessible via mangled name
# print(account.__balance) # AttributeError - no such attribute
warning

Double-underscore name mangling does NOT make an attribute private. Anyone can access _BankAccount__balance directly - it is just a different name. The mangling is purely a collision-prevention mechanism for inheritance hierarchies, not an access control mechanism. If you need to inspect a mangled attribute from outside the class:

# Access the mangled name directly:
print(account._BankAccount__balance) # 1500 - works fine

# Or use vars() to see all attributes:
print(vars(account)) # {'_BankAccount__balance': 1000}

Python does not have true private attributes. The __name convention is a naming transform, not a security boundary.

Why Name Mangling Exists

Name mangling is not primarily for privacy. It exists to prevent accidental name clashes in inheritance hierarchies. Without mangling, a subclass that uses the same internal attribute name would silently overwrite the parent's attribute:

class Widget:
def __init__(self):
self.__state = "normal" # stored as _Widget__state

def get_state(self):
return self.__state # _Widget__state

class Button(Widget):
def __init__(self):
super().__init__()
self.__state = "clickable" # stored as _Button__state - different attribute!

def click(self):
self.__state = "clicked" # modifies _Button__state, NOT _Widget__state

b = Button()
print(b.__dict__)
# {'_Widget__state': 'normal', '_Button__state': 'clickable'}
# Both attributes coexist - no collision

print(b.get_state()) # 'normal' - Widget's __state is untouched
b.click()
print(b.__dict__)
# {'_Widget__state': 'normal', '_Button__state': 'clicked'}

If Widget had used self._state (single underscore), Button.__init__ setting self._state = "clickable" would have overwritten the parent's value - a silent, hard-to-debug collision.

When to Use Double Underscore

Use __name (double underscore) when:

  • You are writing a class intended to be subclassed, and you have internal attributes that subclasses must not accidentally collide with
  • You are implementing a framework or library where you control the base class and want to protect implementation details from being clobbered by subclass authors who do not know the internals

Do not use __name for regular "I want this private" use. The single underscore convention suffices for that - it communicates intent without the confusing mangling behavior.

# Correct use of __name: protecting framework internals
class BaseView:
def __init__(self):
self.__dispatch_table = {} # subclasses MUST NOT override this
self.__middleware = [] # same

def _setup(self): # intended override point - single underscore
pass

class MyView(BaseView):
def __init__(self):
super().__init__()
# self.__dispatch_table = {} # would NOT collide - stored as _MyView__dispatch_table
self._my_private_thing = 42 # normal internal attribute

Part 3 - @property for Controlled Access

The Problem Properties Solve

Plain attributes give callers direct read/write access with no ability to validate or compute. Adding validation later requires changing the API:

# Version 1 - plain attribute
class Circle:
def __init__(self, radius):
self.radius = radius # caller can set: c.radius = -10 - invalid!

# Version 2 - must change to method, breaking callers
class Circle:
def __init__(self, radius):
self._radius = radius

def get_radius(self):
return self._radius

def set_radius(self, value):
if value < 0:
raise ValueError("radius cannot be negative")
self._radius = value

# All callers must change: c.radius → c.get_radius()
# This is how Java does it. Python has a better way.

@property lets you start with a plain attribute, add validation later, and never break callers:

class Circle:
def __init__(self, radius: float):
self.radius = radius # goes through the setter immediately

@property
def radius(self) -> float:
return self._radius

@radius.setter
def radius(self, value: float) -> None:
if value < 0:
raise ValueError(f"radius must be non-negative, got {value}")
self._radius = value

@property
def area(self) -> float:
import math
return math.pi * self._radius ** 2

@property
def circumference(self) -> float:
import math
return 2 * math.pi * self._radius

c = Circle(5)
print(c.radius) # 5
print(c.area) # 78.53981...
print(c.circumference) # 31.41592...

c.radius = 10 # goes through setter - validated
print(c.area) # 314.1592...

try:
c.radius = -1 # ValueError: radius must be non-negative, got -1
except ValueError as e:
print(e)

The callers of Circle never change their code. c.radius still reads and writes as an attribute - the validation is invisible to them.

tip

Use @property only when you actually need something beyond plain attribute storage: validation, type coercion, computed/derived values, or side effects on read/write. For attributes that are simply stored and retrieved with no logic, a plain attribute (self.name = name) is always preferable. Adding @property for no reason adds overhead, visual noise, and a _backing_attr name that serves no purpose.

The @property Mechanics

@property is actually a built-in class, not just a decorator. It works like this:

class Temperature:
@property
def celsius(self):
return self._celsius

@celsius.setter
def celsius(self, value):
self._celsius = value

# Equivalent without decorator syntax:
class Temperature:
def _get_celsius(self):
return self._celsius

def _set_celsius(self, value):
self._celsius = value

celsius = property(_get_celsius, _set_celsius)

property is a descriptor (covered in Part 5). When you write obj.celsius, Python finds celsius in the class dict, sees it is a descriptor (has __get__), and calls celsius.__get__(obj, type(obj)) instead of returning celsius directly.

Read-Only Properties

A property without a setter is read-only:

class Rectangle:
def __init__(self, width: float, height: float):
self.width = width
self.height = height

@property
def area(self) -> float:
return self.width * self.height

@property
def perimeter(self) -> float:
return 2 * (self.width + self.height)

@property
def is_square(self) -> bool:
return self.width == self.height

r = Rectangle(4, 6)
print(r.area) # 24.0
print(r.perimeter) # 20.0
print(r.is_square) # False

try:
r.area = 30 # AttributeError: can't set attribute
except AttributeError as e:
print(e)

Read-only properties are used for derived / computed values that depend on other attributes - you should not be able to set area directly; it is a function of width and height.

@deleter - Handling Attribute Deletion

class CachedData:
def __init__(self, source):
self._source = source
self._cache = None

@property
def data(self):
if self._cache is None:
print("Loading from source...")
self._cache = list(self._source) # simulate loading
return self._cache

@data.setter
def data(self, value):
self._cache = value

@data.deleter
def data(self):
print("Clearing cache...")
self._cache = None

source = range(5)
cd = CachedData(source)

print(cd.data) # Loading from source... [0, 1, 2, 3, 4]
print(cd.data) # [0, 1, 2, 3, 4] - from cache, no "Loading" message

del cd.data # Clearing cache...
print(cd.data) # Loading from source... [0, 1, 2, 3, 4] - reloaded

Part 4 - Validation Patterns in Property Setters

Type and Range Validation

from typing import Optional

class Employee:
def __init__(
self,
name: str,
salary: float,
department: str,
manager: Optional["Employee"] = None,
):
self.name = name # each goes through its setter
self.salary = salary
self.department = department
self.manager = manager

@property
def name(self) -> str:
return self._name

@name.setter
def name(self, value: str) -> None:
value = value.strip()
if not value:
raise ValueError("name must not be empty")
if len(value) > 100:
raise ValueError(f"name too long: {len(value)} chars (max 100)")
self._name = value

@property
def salary(self) -> float:
return self._salary

@salary.setter
def salary(self, value: float) -> None:
if not isinstance(value, (int, float)):
raise TypeError(f"salary must be numeric, got {type(value).__name__}")
value = float(value)
if value < 0:
raise ValueError(f"salary cannot be negative, got {value}")
if value > 10_000_000:
raise ValueError(f"salary exceeds maximum: {value}")
self._salary = value

@property
def department(self) -> str:
return self._department

@department.setter
def department(self, value: str) -> None:
VALID = {"Engineering", "Product", "Sales", "Finance", "HR", "Legal"}
if value not in VALID:
raise ValueError(f"Unknown department {value!r}. Valid: {VALID}")
self._department = value

@property
def manager(self) -> Optional["Employee"]:
return self._manager

@manager.setter
def manager(self, value: Optional["Employee"]) -> None:
if value is not None and not isinstance(value, Employee):
raise TypeError(f"manager must be an Employee, got {type(value).__name__}")
if value is self:
raise ValueError("An employee cannot be their own manager")
self._manager = value

def __repr__(self) -> str:
mgr = self._manager.name if self._manager else None
return f"Employee(name={self._name!r}, salary={self._salary}, dept={self._department!r}, manager={mgr!r})"

# All validation runs at construction time (through setters in __init__)
alice = Employee("Alice Chen", 120_000, "Engineering")
bob = Employee("Bob Smith", 95_000, "Engineering", manager=alice)

print(repr(alice))
# Employee(name='Alice Chen', salary=120000.0, dept='Engineering', manager=None)

try:
bad = Employee("", 50_000, "Engineering")
except ValueError as e:
print(e) # name must not be empty

try:
bad = Employee("Dave", -1, "Engineering")
except ValueError as e:
print(e) # salary cannot be negative, got -1.0

try:
alice.manager = alice # self-reference
except ValueError as e:
print(e) # An employee cannot be their own manager

Caching / Lazy Properties

For expensive computations, cache the result after the first access:

import hashlib

class Document:
def __init__(self, content: bytes):
self._content = content
self._sha256 = None # lazy - computed on first access
self._word_count = None # lazy

@property
def content(self) -> bytes:
return self._content

@property
def sha256(self) -> str:
if self._sha256 is None:
self._sha256 = hashlib.sha256(self._content).hexdigest()
return self._sha256

@property
def word_count(self) -> int:
if self._word_count is None:
text = self._content.decode("utf-8", errors="ignore")
self._word_count = len(text.split())
return self._word_count

doc = Document(b"Hello world this is a test document")
print(doc.sha256) # computed and cached
print(doc.sha256) # returned from cache
print(doc.word_count) # computed and cached

Python 3.8+ provides functools.cached_property for this pattern, which eliminates the need for a manual None guard:

from functools import cached_property

class Document:
def __init__(self, content: bytes):
self._content = content

@cached_property
def sha256(self) -> str:
return hashlib.sha256(self._content).hexdigest()

@cached_property
def word_count(self) -> int:
return len(self._content.decode("utf-8", errors="ignore").split())

doc = Document(b"Hello world")
print(doc.sha256) # computed once
print(doc.sha256) # from __dict__ - bypasses the property on second access
print(doc.__dict__) # {'sha256': '...', 'word_count': 2}

cached_property works by writing the computed value directly into instance.__dict__ on first access. Subsequent accesses find it in __dict__ before Python even checks the class - so the property is never called again. This requires that the instance has a __dict__ (no __slots__).

Part 5 - __slots__ with Inheritance

__slots__ was introduced in Lesson 01. Here is the full picture for inheritance:

class Base:
__slots__ = ("x", "y")

def __init__(self, x, y):
self.x = x
self.y = y

class Child(Base):
__slots__ = ("z",) # only ADD new slots here - do not repeat parent slots

def __init__(self, x, y, z):
super().__init__(x, y)
self.z = z

c = Child(1, 2, 3)
print(c.x, c.y, c.z) # 1 2 3
# c.w = 4 # AttributeError - no __dict__, no slot for 'w'

If any class in the inheritance chain does not define __slots__, instances will have __dict__ (the memory savings are lost):

class NoSlots:
pass # has __dict__

class SlottedChild(NoSlots):
__slots__ = ("x",)

# SlottedChild instances still have __dict__ because NoSlots does
sc = SlottedChild()
sc.x = 1
sc.anything = "arbitrary" # works - __dict__ is available
print(sc.__dict__) # {'anything': 'arbitrary'}

@property works with __slots__ - the property lives in the class __dict__, not the instance. The slot stores only the underlying _value:

class PositiveFloat:
__slots__ = ("_value",)

def __init__(self, value: float):
self.value = value # goes through property setter

@property
def value(self) -> float:
return self._value

@value.setter
def value(self, v: float) -> None:
if v < 0:
raise ValueError(f"must be non-negative, got {v}")
self._value = float(v)

pf = PositiveFloat(3.14)
print(pf.value) # 3.14
# pf.other = 5 # AttributeError - slots enforced
danger

Do not use __slots__ as a default optimisation for every class. It trades flexibility for memory savings and introduces significant constraints:

  • Breaks cached_property (which writes to instance.__dict__)
  • Breaks mixins and multiple inheritance if parent classes do not also define __slots__
  • Prevents adding new attributes to instances at runtime
  • Complicates pickling in some cases

Use __slots__ only when you have measured a memory problem with many instances (tens of thousands or more) of a specific class and profiling confirms that __dict__ overhead is the bottleneck.

Part 6 - The Descriptor Protocol

What Is a Descriptor?

A descriptor is any object that defines __get__, __set__, or __delete__. When a descriptor instance is stored as a class attribute, Python invokes these methods instead of returning the descriptor object directly.

class MyDescriptor:
def __get__(self, obj, objtype=None):
print(f"__get__ called: obj={obj!r}, objtype={objtype.__name__!r}")
return 42

def __set__(self, obj, value):
print(f"__set__ called: obj={obj!r}, value={value!r}")

def __delete__(self, obj):
print(f"__delete__ called: obj={obj!r}")

class MyClass:
attr = MyDescriptor() # descriptor stored as CLASS attribute

obj = MyClass()

x = obj.attr # __get__ called: obj=<MyClass...>, objtype='MyClass'
obj.attr = 10 # __set__ called: obj=<MyClass...>, value=10
del obj.attr # __delete__ called: obj=<MyClass...>

# Accessing via the class (not an instance) also calls __get__ with obj=None
y = MyClass.attr # __get__ called: obj=None, objtype='MyClass'

Data vs Non-Data Descriptors

There are two kinds of descriptors:

  • Data descriptor: defines __set__ (and/or __delete__)
  • Non-data descriptor: defines only __get__

The distinction controls how they interact with the instance __dict__:

# Data descriptor - __set__ defined
# Takes priority OVER instance __dict__
# attribute lookup: data_descriptor > instance.__dict__ > non-data_descriptor > class

# Non-data descriptor - only __get__
# Instance __dict__ takes priority OVER it
# attribute lookup: data_descriptor > instance.__dict__ > non-data_descriptor > class

property is a data descriptor (it defines __get__, __set__, and __delete__). That is why obj.x = value goes through the property setter even though Python would normally write to instance.__dict__.

Functions are non-data descriptors (they define only __get__). That is why you can shadow a method by setting an instance attribute with the same name.

@property Is Implemented as a Descriptor

You can see this directly:

class Circle:
@property
def radius(self):
return self._radius

print(type(Circle.__dict__["radius"])) # <class 'property'>
print(Circle.__dict__["radius"]) # <property object at 0x...>
print(hasattr(Circle.__dict__["radius"], "__get__")) # True
print(hasattr(Circle.__dict__["radius"], "__set__")) # True (returns AttributeError for read-only)
print(hasattr(Circle.__dict__["radius"], "__delete__")) # True

When you write c.radius:

  1. Python looks in c.__dict__ - not found
  2. Python looks in Circle.__dict__ - finds the property object
  3. Python sees that property is a data descriptor (has __get__)
  4. Python calls Circle.__dict__["radius"].__get__(c, Circle)
  5. That calls your getter function with self=c

Writing Your Own Descriptor

Descriptors are the tool for reusable attribute behaviour - validation, logging, type coercion - that you want to apply across multiple attributes and multiple classes:

class TypedAttribute:
"""Descriptor that enforces a type for an attribute."""

def __init__(self, name: str, expected_type: type):
self.name = name
self.expected_type = expected_type
# Internal storage key - use the class-qualified name to avoid collisions
self._storage_name = f"_typed_{name}"

def __set_name__(self, owner, name):
# Called when the descriptor is assigned to a class attribute.
# owner = the class, name = the attribute name used in the class body.
self.name = name
self._storage_name = f"_typed_{name}"

def __get__(self, obj, objtype=None):
if obj is None:
return self # class-level access returns the descriptor itself
return getattr(obj, self._storage_name, None)

def __set__(self, obj, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name!r} must be {self.expected_type.__name__}, "
f"got {type(value).__name__}"
)
setattr(obj, self._storage_name, value)

def __delete__(self, obj):
if hasattr(obj, self._storage_name):
delattr(obj, self._storage_name)

class Product:
name = TypedAttribute("name", str)
price = TypedAttribute("price", float)
quantity = TypedAttribute("quantity", int)

def __init__(self, name: str, price: float, quantity: int):
self.name = name # __set__ called - type checked
self.price = price # __set__ called - type checked
self.quantity = quantity # __set__ called - type checked

def __repr__(self):
return f"Product(name={self.name!r}, price={self.price}, quantity={self.quantity})"

p = Product("Widget", 9.99, 100)
print(p) # Product(name='Widget', price=9.99, quantity=100)

try:
p.price = "expensive" # TypeError: 'price' must be float, got str
except TypeError as e:
print(e)

try:
p.quantity = 50.5 # TypeError: 'quantity' must be int, got float
except TypeError as e:
print(e)

The same TypedAttribute descriptor can be reused across every class - one implementation, consistent behaviour everywhere. This is far more powerful than copy-pasting validation into each @property setter.

__set_name__ - Descriptor Naming (Python 3.6+)

In the example above, __set_name__ is called by the class machinery when the descriptor is assigned as a class attribute. It provides the actual attribute name so the descriptor does not need to be told separately:

class Validated:
"""Descriptor with automatic name detection."""

def __set_name__(self, owner, name):
self.public_name = name
self.private_name = "_" + name # store backing value here

def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private_name, None)

def __set__(self, obj, value):
self._validate(value)
setattr(obj, self.private_name, value)

def _validate(self, value):
pass # override in subclass

class PositiveNumber(Validated):
def _validate(self, value):
if not isinstance(value, (int, float)) or value <= 0:
raise ValueError(f"{self.public_name!r} must be a positive number, got {value!r}")

class Temperature:
celsius = PositiveNumber() # __set_name__ → public='celsius', private='_celsius'
kelvin = PositiveNumber() # __set_name__ → public='kelvin', private='_kelvin'

def __init__(self, celsius):
self.celsius = celsius

t = Temperature(100)
print(t.celsius) # 100

try:
t.celsius = -10 # ValueError: 'celsius' must be a positive number, got -10
except ValueError as e:
print(e)

@classmethod and @staticmethod Are Descriptors

Once you understand the descriptor protocol, @classmethod and @staticmethod are demystified:

class Foo:
@classmethod
def bar(cls):
return cls

# classmethod is a descriptor
print(type(Foo.__dict__["bar"])) # <class 'classmethod'>

# When accessed via the class:
# Foo.bar calls bar.__get__(None, Foo) → returns a bound method with cls=Foo

# When accessed via an instance:
# obj.bar calls bar.__get__(obj, Foo) → still returns cls=Foo (not the instance)
class Foo:
@staticmethod
def baz():
return "static"

print(type(Foo.__dict__["baz"])) # <class 'staticmethod'>

# staticmethod.__get__ returns the underlying function unchanged -
# no binding to class or instance

This is why @classmethod always gets the class (not the instance) as its first argument - it is the descriptor's __get__ that wraps the function and passes cls.

Part 7 - Combining Techniques

A Full Example: A Typed, Validated Domain Object

from typing import Optional
import re

class StringField:
"""Descriptor: string with optional regex validation and max length."""

def __set_name__(self, owner, name):
self.name = name
self._attr = f"_{name}"

def __init__(self, max_length: int = 255, pattern: Optional[str] = None):
self.max_length = max_length
self._pattern = re.compile(pattern) if pattern else None

def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self._attr, None)

def __set__(self, obj, value: str):
if not isinstance(value, str):
raise TypeError(f"{self.name!r} must be str, got {type(value).__name__}")
value = value.strip()
if len(value) > self.max_length:
raise ValueError(f"{self.name!r} too long: {len(value)} > {self.max_length}")
if self._pattern and not self._pattern.fullmatch(value):
raise ValueError(f"{self.name!r} does not match required pattern")
setattr(obj, self._attr, value)

class PositiveFloat:
def __set_name__(self, owner, name):
self.name = name
self._attr = f"_{name}"

def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self._attr, 0.0)

def __set__(self, obj, value):
value = float(value)
if value < 0:
raise ValueError(f"{self.name!r} must be non-negative, got {value}")
setattr(obj, self._attr, value)

class Product:
sku = StringField(max_length=20, pattern=r"[A-Z]{3}-\d{4}")
name = StringField(max_length=100)
price = PositiveFloat()
weight_kg = PositiveFloat()

def __init__(self, sku: str, name: str, price: float, weight_kg: float = 0.0):
self.sku = sku
self.name = name
self.price = price
self.weight_kg = weight_kg

@property
def price_formatted(self) -> str:
return f"${self.price:,.2f}"

def __repr__(self):
return (
f"Product(sku={self.sku!r}, name={self.name!r}, "
f"price={self.price}, weight_kg={self.weight_kg})"
)

p = Product("WGT-0042", "Deluxe Widget", 49.99, weight_kg=0.35)
print(repr(p))
# Product(sku='WGT-0042', name='Deluxe Widget', price=49.99, weight_kg=0.35)
print(p.price_formatted) # $49.99

try:
Product("invalid-sku", "Widget", 10) # ValueError - SKU pattern mismatch
except ValueError as e:
print(e)

try:
p.price = -5 # ValueError - negative price
except ValueError as e:
print(e)

Common Mistakes

Mistake 1 - Thinking __name Makes Attributes Private

# Misconception: __balance is inaccessible from outside
class Account:
def __init__(self, balance):
self.__balance = balance

a = Account(100)
# a.__balance → AttributeError (but this is NOT true privacy)
print(a._Account__balance) # 100 - the attribute is RIGHT THERE

Name mangling is name collision prevention for inheritance, not access control. Anyone can access the mangled name.

Mistake 2 - Property Getter Without Underlying Storage Key

# Wrong - infinite recursion
class Bad:
@property
def value(self):
return self.value # self.value calls __get__ again → RecursionError

# Right - use a different name for the backing storage
class Good:
@property
def value(self):
return self._value # _value is a plain instance attribute

@value.setter
def value(self, v):
self._value = v

Mistake 3 - Defining the Setter Without the Property

# Wrong - setter must be defined on an existing property object
class Bad:
@value.setter # NameError: name 'value' is not defined
def value(self, v):
self._value = v

# Right - define property first, then setter
class Good:
@property
def value(self):
return self._value

@value.setter # value is now defined as the property above
def value(self, v):
self._value = v

Mistake 4 - Descriptor Without __set_name__ Storing Wrong Name

# Wrong - hardcoding the attribute name in the descriptor
class Validated:
def __init__(self, name):
self._attr = f"_{name}" # must be told the name explicitly

class Product:
price = Validated("price") # you have to repeat "price" twice

# Right - use __set_name__
class Validated:
def __set_name__(self, owner, name):
self._attr = f"_{name}" # Python tells the descriptor its name

class Product:
price = Validated() # name is set automatically

Mistake 5 - Using cached_property with __slots__

# Wrong - cached_property writes to instance.__dict__, which doesn't exist with __slots__
class Bad:
__slots__ = ("_value",)

@cached_property
def computed(self): # will raise TypeError on first access
return expensive_computation()

# Right - use a manual cache slot, or remove __slots__
class Good:
__slots__ = ("_value", "_computed_cache")

@property
def computed(self):
if self._computed_cache is None:
self._computed_cache = expensive_computation()
return self._computed_cache

Engineering Checklist

Before moving to the next lesson, verify you can answer these without looking:

  1. What does a single underscore prefix (_name) communicate, and what does Python enforce about it?
  2. What transformation does Python apply to __name defined inside class Foo?
  3. Why does name mangling exist - what problem does it prevent?
  4. If obj.attr accesses a property, at what level does the property object live - instance or class?
  5. What makes a descriptor a "data descriptor" vs a "non-data descriptor", and why does it matter?
  6. In what order does Python look up obj.x: class descriptors, instance __dict__, or something else?
  7. What does __set_name__ receive as arguments, and when is it called?
  8. Why does @classmethod always receive cls - which mechanism delivers it?
  9. What happens when cached_property is used on a class with __slots__?

Quick Reference

# Single underscore - convention only
self._internal = value

# Double underscore - name mangling
class Foo:
def __init__(self):
self.__secret = 42 # stored as _Foo__secret
# Access: foo._Foo__secret

# Property - controlled access
class MyClass:
@property
def value(self) -> int:
return self._value

@value.setter
def value(self, v: int) -> None:
if v < 0:
raise ValueError(f"must be non-negative, got {v}")
self._value = v

@value.deleter
def value(self) -> None:
del self._value

# Descriptor - reusable attribute behaviour
class TypeChecked:
def __set_name__(self, owner, name):
self.name = name
self._attr = f"_{name}"

def __get__(self, obj, objtype=None):
if obj is None: return self
return getattr(obj, self._attr, None)

def __set__(self, obj, value):
if not isinstance(value, int):
raise TypeError(f"{self.name} must be int")
setattr(obj, self._attr, value)

class Config:
port = TypeChecked() # __set_name__ fires here
workers = TypeChecked()

Graded Practice

Level 1 - Predict the Output

Trace through the following code carefully. Predict each output before running.

class Temperature:
def __init__(self, celsius):
self._celsius = celsius

@property
def celsius(self):
return self._celsius

@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Below absolute zero!")
self._celsius = value

@property
def fahrenheit(self):
return self._celsius * 9 / 5 + 32

t = Temperature(100)

# Question 1:
print(t.celsius)

# Question 2:
print(t.fahrenheit)

# Question 3:
t.celsius = 0
print(t.fahrenheit)

# Question 4:
try:
t.celsius = -300
except ValueError as e:
print(e)

# Question 5:
try:
t.fahrenheit = 212
except AttributeError as e:
print(type(e).__name__)
Show Answer
# Question 1: print(t.celsius)
100
# The getter returns self._celsius which was set to 100 in __init__

# Question 2: print(t.fahrenheit)
212.0
# 100 * 9 / 5 + 32 = 180 + 32 = 212.0

# Question 3: t.celsius = 0; print(t.fahrenheit)
32.0
# Setter validates 0 >= -273.15 (passes), stores 0; 0 * 9/5 + 32 = 32.0

# Question 4: t.celsius = -300 → ValueError
Below absolute zero!
# -300 < -273.15 triggers the guard in the setter

# Question 5: t.fahrenheit = 212 → AttributeError
AttributeError
# fahrenheit is a read-only property (getter only, no setter)
# Python raises AttributeError: can't set attribute

Key concepts: the getter returns the backing _celsius attribute; the setter validates before storing; a property without a setter raises AttributeError on assignment; computed properties (fahrenheit) always derive from the stored value.

Level 2 - Debug Challenge

The following descriptor-based class has three bugs. Find and fix them all.

class RangedInt:
"""Descriptor that enforces an integer within [min_val, max_val]."""

def __init__(self, min_val, max_val):
self.min_val = min_val
self.max_val = max_val
# Bug 1: attribute name not set up

def __get__(self, obj, objtype=None):
return getattr(obj, self._attr) # Bug 2: what if obj is None?

def __set__(self, obj, value):
if not isinstance(value, int):
raise TypeError(f"must be int, got {type(value).__name__}")
if not (self.min_val <= value <= self.max_val):
raise ValueError(f"must be in [{self.min_val}, {self.max_val}], got {value}")
setattr(obj, self._attr, value) # Bug 3: _attr never set if __set_name__ missing

class Config:
workers = RangedInt(1, 32)
timeout = RangedInt(1, 300)

def __init__(self, workers, timeout):
self.workers = workers
self.timeout = timeout

c = Config(4, 30)
print(c.workers) # should print 4
print(Config.workers) # should return the descriptor, not raise
Show Answer

Bug 1 and Bug 3: RangedInt has no __set_name__ method, so self._attr is never set. Both __get__ and __set__ reference self._attr, which will raise AttributeError immediately.

Bug 2: __get__ does not handle obj is None (class-level access). When you access Config.workers, obj is None and getattr(None, self._attr) raises AttributeError rather than returning the descriptor itself.

Fixed version:

class RangedInt:
def __init__(self, min_val, max_val):
self.min_val = min_val
self.max_val = max_val
self._attr = None # will be set by __set_name__

def __set_name__(self, owner, name): # Fix 1 and 3: add __set_name__
self._attr = f"_{name}"

def __get__(self, obj, objtype=None):
if obj is None: # Fix 2: handle class-level access
return self
return getattr(obj, self._attr)

def __set__(self, obj, value):
if not isinstance(value, int):
raise TypeError(f"must be int, got {type(value).__name__}")
if not (self.min_val <= value <= self.max_val):
raise ValueError(f"must be in [{self.min_val}, {self.max_val}], got {value}")
setattr(obj, self._attr, value)

class Config:
workers = RangedInt(1, 32)
timeout = RangedInt(1, 300)

def __init__(self, workers, timeout):
self.workers = workers
self.timeout = timeout

c = Config(4, 30)
print(c.workers) # 4
print(Config.workers) # <__main__.RangedInt object at 0x...>

try:
c.workers = 0 # ValueError: must be in [1, 32], got 0
except ValueError as e:
print(e)

Level 3 - Design Challenge

Design a BankAccount class that:

  1. Has a balance property that is read-only from outside (no public setter)
  2. Has deposit(amount) and withdraw(amount) methods that validate the amount (must be positive) and for withdrawals check sufficient funds
  3. Has a _transaction_history list (single underscore - internal) that logs every transaction as a tuple (type, amount, new_balance)
  4. Has a transaction_count read-only property
  5. Uses double underscore (__owner) for an owner name that must not be overridden by subclasses
  6. A SavingsAccount subclass adds a minimum_balance constraint - withdrawals that would go below the minimum are rejected
Show Answer
class BankAccount:
def __init__(self, owner: str, initial_balance: float = 0.0):
self.__owner = owner # double underscore: subclass collision prevention
self._balance = initial_balance # backing store for property
self._transaction_history = [] # single underscore: internal

@property
def owner(self) -> str:
return self.__owner

@property
def balance(self) -> float:
return self._balance

@property
def transaction_count(self) -> int:
return len(self._transaction_history)

def _record(self, txn_type: str, amount: float) -> None:
"""Internal: record a transaction."""
self._transaction_history.append((txn_type, amount, self._balance))

def deposit(self, amount: float) -> None:
if not isinstance(amount, (int, float)) or amount <= 0:
raise ValueError(f"deposit amount must be positive, got {amount!r}")
self._balance += amount
self._record("deposit", amount)

def withdraw(self, amount: float) -> None:
if not isinstance(amount, (int, float)) or amount <= 0:
raise ValueError(f"withdrawal amount must be positive, got {amount!r}")
if amount > self._balance:
raise ValueError(
f"insufficient funds: balance={self._balance:.2f}, requested={amount:.2f}"
)
self._balance -= amount
self._record("withdrawal", amount)

def __repr__(self) -> str:
return f"BankAccount(owner={self.__owner!r}, balance={self._balance:.2f})"


class SavingsAccount(BankAccount):
def __init__(self, owner: str, initial_balance: float = 0.0, minimum_balance: float = 100.0):
super().__init__(owner, initial_balance)
self._minimum_balance = minimum_balance

def withdraw(self, amount: float) -> None:
if self._balance - amount < self._minimum_balance:
raise ValueError(
f"withdrawal would breach minimum balance of {self._minimum_balance:.2f}"
)
super().withdraw(amount) # delegates to BankAccount.withdraw for validation

def __repr__(self) -> str:
return (
f"SavingsAccount(owner={self.owner!r}, balance={self.balance:.2f}, "
f"minimum={self._minimum_balance:.2f})"
)


# Demo
acc = BankAccount("Alice", 500.0)
acc.deposit(200)
acc.withdraw(100)
print(repr(acc)) # BankAccount(owner='Alice', balance=600.00)
print(acc.transaction_count) # 2

savings = SavingsAccount("Bob", 500.0, minimum_balance=100.0)
savings.deposit(100)
savings.withdraw(400) # balance = 200 - still above 100 minimum, allowed
print(repr(savings)) # SavingsAccount(owner='Bob', balance=200.00, minimum=100.00)

try:
savings.withdraw(150) # would leave 50, below minimum
except ValueError as e:
print(e) # withdrawal would breach minimum balance of 100.00

# Double underscore protects __owner across subclasses:
# savings.__owner → AttributeError
# savings._BankAccount__owner → 'Bob' (accessible via mangled name)
print(savings._BankAccount__owner) # Bob - demonstrating it's not truly private

Key Takeaways

  • Single underscore (_name) is a convention, not enforcement. It signals "internal use" and is respected by import *, but nothing prevents access from outside.
  • Double underscore (__name) is name mangling: __attr in class Foo is stored as _Foo__attr. It exists to prevent accidental attribute collisions in inheritance hierarchies - not to create private attributes. Anyone can still access _Foo__attr directly.
  • @property lets you intercept attribute reads and writes without changing the caller's API. Use it only when you need validation, type coercion, computed values, or side effects - not as a default for every attribute.
  • A property without a setter is read-only; attempting assignment raises AttributeError.
  • The descriptor protocol (__get__, __set__, __delete__) is the underlying mechanism for @property, @classmethod, and @staticmethod. Any class that defines these methods can be used as a class-level attribute and gains the same intercept power.
  • Data descriptors (define __set__) take priority over instance.__dict__. Non-data descriptors (define only __get__) are overridden by instance.__dict__. This ordering explains why property setters work even when the instance dict is checked first.
  • __set_name__ (Python 3.6+) is called automatically when a descriptor is assigned to a class attribute, providing the attribute name without manual configuration.
  • Avoid __slots__ by default. It breaks cached_property, complicates multiple inheritance, and removes the ability to add attributes dynamically. Use it only when profiling shows that __dict__ memory overhead is a measurable problem.

What's Next

Lesson 06 covers inheritance at engineering depth - what inheritance actually does at the namespace level, single and multiple inheritance, the Method Resolution Order (MRO), cooperative super(), the fragile base class problem, isinstance and issubclass, and when inheritance is the right tool (true is-a relationships) versus when composition is better.

Inheritance is the most frequently misused feature in OOP. The MRO, which you have seen referenced in earlier lessons, is now covered in full - because without understanding it, multiple inheritance and mixin patterns will remain mysterious.

© 2026 EngineersOfAI. All rights reserved.