Skip to content
Low Level Design Mastery Logo
LowLevelDesign Mastery

Pythonic Code

Write Python code that is clean, readable, and follows Python conventions.

Pythonic code follows Python’s design philosophy and idioms, making it more readable, maintainable, and efficient. This guide covers essential Python features that help you write idiomatic Python code.

Dunder methods (short for “double underscore” methods) are special methods in Python that have names starting and ending with double underscores (__method__). They allow you to define the behavior of your objects for built-in operations, such as arithmetic operations, comparisons, and type conversions.

vector.py
class Vector:
"""A 2D vector class demonstrating dunder methods"""
def __init__(self, x: float, y: float):
self.x = x
self.y = y
def __add__(self, other):
"""Enables: vector1 + vector2"""
return Vector(self.x + other.x, self.y + other.y)
def __sub__(self, other):
"""Enables: vector1 - vector2"""
return Vector(self.x - other.x, self.y - other.y)
def __mul__(self, scalar):
"""Enables: vector * scalar"""
return Vector(self.x * scalar, self.y * scalar)
def __eq__(self, other):
"""Enables: vector1 == vector2"""
return self.x == other.x and self.y == other.y
def __repr__(self):
"""Official string representation (for developers)"""
return f"Vector({self.x}, {self.y})"
def __str__(self):
"""Informal string representation (for users)"""
return f"({self.x}, {self.y})"
def __len__(self):
"""Enables: len(vector)"""
return int((self.x ** 2 + self.y ** 2) ** 0.5)
# Usage
v1 = Vector(3, 4)
v2 = Vector(1, 2)
v3 = v1 + v2 # Uses __add__
print(v3) # Uses __str__: (4, 6)
print(repr(v3)) # Uses __repr__: Vector(4, 6)
print(len(v1)) # Uses __len__: 5
classDiagram
    class Vector {
        -x: float
        -y: float
        +__init__(x, y)
        +__add__(other) Vector
        +__sub__(other) Vector
        +__mul__(scalar) Vector
        +__eq__(other) bool
        +__repr__() str
        +__str__() str
        +__len__() int
    }
Dunder MethodPurposeExample Usage
__init__Object initializationobj = MyClass()
__repr__Official string representationrepr(obj)
__str__Informal string representationstr(obj)
__add__Addition operatorobj1 + obj2
__sub__Subtraction operatorobj1 - obj2
__mul__Multiplication operatorobj1 * obj2
__eq__Equality comparisonobj1 == obj2
__lt__Less than comparisonobj1 < obj2
__len__Lengthlen(obj)
__getitem__Index accessobj[key]
__setitem__Index assignmentobj[key] = value
__iter__Returns iteratorfor item in obj
__next__Next item in iteratornext(iterator)
__contains__Membership testitem in obj
__call__Makes object callableobj()
__enter__Context manager entrywith obj:
__exit__Context manager exitwith obj:

Context managers in Python are used to manage resources, ensuring they are properly acquired and released. They are typically used with the with statement and help prevent resource leaks.

Without context managers (error-prone):

bad_file_handling.py
file = open('file.txt', 'r')
try:
content = file.read()
finally:
file.close() # Must remember to close!

With context managers (pythonic):

good_file_handling.py
with open('file.txt', 'r') as file:
content = file.read()
# File is automatically closed after the block
database_context.py
class DatabaseConnection:
"""Custom context manager for database connections"""
def __init__(self, connection_string: str):
self.connection_string = connection_string
self.connection = None
def __enter__(self):
"""Called when entering the 'with' block"""
print("Connecting to database...")
self.connection = self.connect_to_database()
return self.connection
def __exit__(self, exc_type, exc_value, traceback):
"""Called when exiting the 'with' block"""
print("Closing database connection...")
if self.connection:
self.connection.close()
return False # Don't suppress exceptions
def connect_to_database(self):
# Simulate database connection
return {"status": "connected"}
# Usage
with DatabaseConnection("postgresql://localhost/db") as db:
print("Using database:", db)
# Database operations here
# Connection automatically closed

Function-Based Context Manager (using contextlib)

Section titled “Function-Based Context Manager (using contextlib)”
contextlib_example.py
from contextlib import contextmanager
@contextmanager
def database_connection(connection_string: str):
"""Function-based context manager"""
print("Connecting to database...")
connection = connect_to_database(connection_string)
try:
yield connection # Value returned to 'as' variable
finally:
print("Closing database connection...")
connection.close()
def connect_to_database(connection_string: str):
return {"status": "connected"}
# Usage
with database_connection("postgresql://localhost/db") as db:
print("Using database:", db)
timer_context.py
import time
from contextlib import contextmanager
@contextmanager
def timer():
"""Context manager to measure execution time"""
start = time.time()
try:
yield
finally:
end = time.time()
print(f"Execution time: {end - start:.2f} seconds")
# Usage
with timer():
# Your code here
time.sleep(1)
print("Task completed")
# Output: Execution time: 1.00 seconds

Understanding the difference between attributes, properties, and methods is crucial for writing pythonic code.

  • Attributes: Variables that belong to an object, storing data
  • Properties: Methods that manage attribute access (getters/setters)
  • Methods: Functions that belong to an object, defining behavior
user_account.py
class UserAccount:
"""Example demonstrating properties for validation"""
def __init__(self, username: str, email: str):
self.username = username
self._email = email # Protected attribute
self._balance = 0.0 # Private attribute
@property
def email(self):
"""Getter for email property"""
return self._email
@email.setter
def email(self, value: str):
"""Setter with validation"""
if "@" not in value:
raise ValueError("Invalid email address")
self._email = value
@property
def balance(self):
"""Read-only property"""
return self._balance
def deposit(self, amount: float):
"""Method to modify balance"""
if amount <= 0:
raise ValueError("Amount must be positive")
self._balance += amount
def withdraw(self, amount: float):
"""Method to modify balance"""
if amount > self._balance:
raise ValueError("Insufficient funds")
self._balance -= amount
# Usage
user = UserAccount("john_doe", "john@example.com")
print(user.email) # Accessing property
user.email = "newemail@example.com" # Using setter
# user.email = "invalid" # Raises ValueError
user.deposit(100.0)
print(user.balance) # 100.0
# user.balance = 200.0 # AttributeError: can't set attribute
classDiagram
    class UserAccount {
        -username: str
        -_email: str
        -_balance: float
        +email: str (property)
        +balance: float (read-only property)
        +deposit(amount: float)
        +withdraw(amount: float)
    }

Python uses underscores to indicate different levels of access and special meanings:

underscore_examples.py
class MyClass:
def __init__(self):
# Single leading underscore: protected (internal use)
self._protected_var = "I am protected"
# Double leading underscore: private (name mangling)
self.__private_var = "I am private"
# Single trailing underscore: avoid keyword conflicts
self.class_ = "Avoids conflict with 'class' keyword"
def get_private_var(self):
"""Public method to access private variable"""
return self.__private_var
def _internal_method(self):
"""Protected method - internal use only"""
return "Internal logic"
obj = MyClass()
# Protected - accessible but should be treated as internal
print(obj._protected_var) # Works, but not recommended
# Private - name mangling prevents direct access
# print(obj.__private_var) # AttributeError
print(obj.get_private_var()) # Access through method
# Name mangling example
print(obj._MyClass__private_var) # Works (but don't do this!)
PatternMeaningExample
_varProtected (internal use)self._internal_data
var_Avoid keyword conflictsclass_, type_
__varPrivate (name mangling)self.__secret
__var__Special methods (dunder)__init__, __str__
_Unused variablefor _ in range(10)

The dataclasses module provides a decorator to automatically generate special methods, reducing boilerplate code for data-holding classes.

manual_point.py
class Point:
def __init__(self, x: float, y: float):
self.x = x
self.y = y
def __repr__(self):
return f"Point(x={self.x}, y={self.y})"
def __eq__(self, other):
if not isinstance(other, Point):
return False
return self.x == other.x and self.y == other.y
def __hash__(self):
return hash((self.x, self.y))
# Lots of boilerplate code!
dataclass_point.py
from dataclasses import dataclass, field
@dataclass
class Point:
"""Much cleaner with dataclasses!"""
x: float
y: float
# Automatically provides:
# - __init__
# - __repr__
# - __eq__
p1 = Point(1.0, 2.0)
p2 = Point(1.0, 2.0)
print(p1 == p2) # True
print(p1) # Point(x=1.0, y=2.0)
advanced_dataclass.py
from dataclasses import dataclass, field
@dataclass(frozen=True) # Makes instances immutable
class Config:
"""Immutable configuration class"""
host: str = "localhost"
port: int = 8080
timeout: int = 30
# Default value for mutable types
metadata: dict = field(default_factory=dict)
config = Config(port=9000)
print(config) # Config(host='localhost', port=9000, timeout=30, metadata={})
# config.port = 8000 # FrozenInstanceError - cannot modify frozen dataclass
@dataclass
class User:
name: str
email: str
age: int = 0 # Default value
def __post_init__(self):
"""Called after __init__"""
if self.age < 0:
raise ValueError("Age cannot be negative")
user = User("John", "john@example.com", 25)

An object is iterable if it implements __iter__(), which returns an iterator. The iterator must implement __next__() to return the next item.

date_range.py
from datetime import datetime, timedelta
class DateRange:
"""Custom iterable for date ranges"""
def __init__(self, start_date: datetime, end_date: datetime):
self.start_date = start_date
self.end_date = end_date
def __iter__(self):
"""Returns iterator object"""
self.current_date = self.start_date
return self
def __next__(self):
"""Returns next item in sequence"""
if self.current_date > self.end_date:
raise StopIteration
date_to_return = self.current_date
self.current_date += timedelta(days=1)
return date_to_return
# Usage
for date in DateRange(datetime(2023, 1, 1), datetime(2023, 1, 5)):
print(date.strftime("%Y-%m-%d"))
# Output:
# 2023-01-01
# 2023-01-02
# 2023-01-03
# 2023-01-04
# 2023-01-05
paginated_results.py
class PaginatedResults:
"""Iterable for paginated API responses"""
def __init__(self, fetch_page_func, total_pages: int):
self.fetch_page = fetch_page_func
self.total_pages = total_pages
def __iter__(self):
self.current_page = 1
return self
def __next__(self):
if self.current_page > self.total_pages:
raise StopIteration
page_data = self.fetch_page(self.current_page)
self.current_page += 1
return page_data
# Usage
def fetch_api_page(page_num):
return [f"Item {page_num}-{i}" for i in range(5)]
results = PaginatedResults(fetch_api_page, total_pages=3)
for page in results:
print(page)

Containers implement __contains__() for membership testing with the in keyword. They often also implement __len__() and __getitem__().

element in container

This translates to:

container.__contains__(element)
boundary_container.py
class Boundaries:
"""Container that checks if a point is within boundaries"""
def __init__(self, width: float, height: float):
self.width = width
self.height = height
def __contains__(self, point: tuple) -> bool:
"""Check if point (x, y) is within boundaries"""
x, y = point
return 0 <= x <= self.width and 0 <= y <= self.height
class Grid:
"""Grid that uses Boundaries via composition"""
def __init__(self, width: float, height: float):
self.width = width
self.height = height
self.limits = Boundaries(width, height) # Composition
def __contains__(self, point: tuple) -> bool:
"""Delegate to Boundaries"""
return point in self.limits
# Usage
grid = Grid(10, 10)
print((5, 5) in grid) # True
print((15, 5) in grid) # False
print((5, 15) in grid) # False
classDiagram
    class Grid {
        -width: float
        -height: float
        -limits: Boundaries
        +__contains__(point: tuple) bool
    }
    
    class Boundaries {
        -width: float
        -height: float
        +__contains__(point: tuple) bool
    }
    
    Grid *-- Boundaries : composition

__getattr__ and __setattr__ allow you to customize attribute access and assignment behavior.

  • __getattr__(self, name): Called when accessing a non-existent attribute
  • __setattr__(self, name, value): Called every time an attribute is assigned

These act as hooks for attribute access and assignment.

dynamic_attributes.py
class DynamicAttributes:
def __init__(self):
self.existing_attr = "I exist"
def __getattr__(self, name: str):
"""Called only for missing attributes"""
return f"{name} not found, but here's a default value!"
def __setattr__(self, name: str, value):
"""Called for every attribute assignment"""
print(f"Setting attribute {name} to {value}")
super().__setattr__(name, value) # Important: use super()
obj = DynamicAttributes()
print(obj.existing_attr) # "I exist" (doesn't trigger __getattr__)
print(obj.missing_attr) # "missing_attr not found..." (triggers __getattr__)
obj.new_attr = "New Value" # Prints: "Setting attribute new_attr to New Value"
print(obj.new_attr) # "New Value"
lazy_loader.py
class LazyLoader:
"""Load expensive data only when accessed"""
def __init__(self):
self._data = None
self._config = None
def __getattr__(self, name: str):
if name == "data":
if self._data is None:
print("Loading data from database...")
self._data = self._load_data()
return self._data
elif name == "config":
if self._config is None:
print("Loading configuration...")
self._config = self._load_config()
return self._config
raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")
def _load_data(self):
# Simulate expensive operation
return {"users": 1000, "posts": 5000}
def _load_config(self):
return {"theme": "dark", "language": "en"}
loader = LazyLoader()
print(loader.data) # Prints: "Loading data..." then returns data
print(loader.data) # Returns cached data (no loading message)
print(loader.config) # Prints: "Loading configuration..." then returns config
validated_attributes.py
class ValidatedUser:
"""Validate attributes before setting"""
def __init__(self):
self._age = None
self._email = None
def __setattr__(self, name: str, value):
if name == "age":
if not isinstance(value, int) or value < 0:
raise ValueError("Age must be a non-negative integer")
elif name == "email":
if "@" not in str(value):
raise ValueError("Invalid email address")
super().__setattr__(name, value)
user = ValidatedUser()
user.age = 25 # Valid
user.email = "user@example.com" # Valid
# user.age = -5 # ValueError
# user.email = "invalid" # ValueError

Asynchronous programming allows you to perform multiple I/O-bound tasks concurrently without blocking execution. Python’s asyncio library provides the foundation for async code.

Synchronous (blocking) - tasks run sequentially:

sync_example.py
import time
def fetch_data(data_id: str, delay: float):
print(f"Fetching {data_id}...")
time.sleep(delay) # Blocks execution
print(f"Fetched {data_id}")
return data_id
# Sequential execution
start = time.time()
fetch_data("Data 1", 2)
fetch_data("Data 2", 2)
print(f"Total time: {time.time() - start:.2f} seconds")
# Total time: ~4 seconds

Asynchronous (non-blocking) - tasks run concurrently:

async_example.py
import asyncio
async def fetch_data(data_id: str, delay: float):
print(f"Fetching {data_id}...")
await asyncio.sleep(delay) # Non-blocking
print(f"Fetched {data_id}")
return data_id
async def main():
# Run tasks concurrently
start = time.time()
results = await asyncio.gather(
fetch_data("Data 1", 2),
fetch_data("Data 2", 2)
)
print(f"Total time: {time.time() - start:.2f} seconds")
print("Results:", results)
asyncio.run(main())
# Total time: ~2 seconds (both tasks run in parallel)

Real-World Example: Concurrent API Requests

Section titled “Real-World Example: Concurrent API Requests”
async_api.py
import asyncio
import aiohttp
async def fetch_url(session: aiohttp.ClientSession, url: str):
"""Fetch a single URL"""
async with session.get(url) as response:
return await response.text()
async def fetch_multiple_urls(urls: list[str]):
"""Fetch multiple URLs concurrently"""
async with aiohttp.ClientSession() as session:
tasks = [fetch_url(session, url) for url in urls]
results = await asyncio.gather(*tasks)
return results
# Usage
urls = [
"https://api.example.com/data1",
"https://api.example.com/data2",
"https://api.example.com/data3"
]
results = asyncio.run(fetch_multiple_urls(urls))
Dunder MethodPurposeExample Usage
__init__Object initializationobj = MyClass()
__repr__Official string representationrepr(obj)
__str__Informal string representationstr(obj)
__add__Addition operatorobj1 + obj2
__eq__Equality comparisonobj1 == obj2
__len__Lengthlen(obj)
__getitem__Index accessobj[key]
__setitem__Index assignmentobj[key] = value
__iter__Returns iteratorfor item in obj
__next__Next item in iteratornext(iterator)
__contains__Membership testitem in obj
__call__Makes object callableobj()
__enter__Context manager entrywith obj:
__exit__Context manager exitwith obj:
__getattr__Handle missing attributesobj.missing
__setattr__Customize attribute assignmentobj.attr = value
  • Use dunder methods to make your classes work with Python’s built-in operations
  • Context managers ensure proper resource cleanup and are more pythonic than try/finally
  • Properties provide controlled access to attributes with validation
  • Dataclasses reduce boilerplate for data-holding classes
  • Iterables make your objects work with for loops and comprehensions
  • Containers enable membership testing with the in keyword
  • Dynamic attributes allow flexible attribute access and lazy loading
  • Async/await enables concurrent I/O operations without blocking

Writing pythonic code means leveraging Python’s features to write code that is clean, readable, and follows Python conventions.