The Complete Guide to Python Best Practices, Tips & Design Patterns
A principal-engineer-level reference for writing idiomatic, maintainable, performant, and safe Python.
A principal-engineer-level reference for writing idiomatic, maintainable, performant, and safe Python.
Table of Contents
- Philosophy & The Zen of Python
- Code Style & PEP 8
- Naming Conventions
- Type Hints & Static Typing
- Data Structures — Idiomatic Usage
- Functions: Design & Best Practices
- Comprehensions & Generator Expressions
- Iterators & Generators
- Context Managers
- Decorators
- OOP in Python: Classes Done Right
- Dataclasses, NamedTuples & Attrs
- Error Handling & Exceptions
- Modules, Packages & Project Structure
- Concurrency: Threading, Multiprocessing, Asyncio
- Performance Optimization
- Testing Best Practices
- Logging
- Security Best Practices
- Design Patterns in Python
- Common Pitfalls & Anti-Patterns
- Tooling Ecosystem
- Final Checklist
1. Philosophy & The Zen of Python
Run import this and internalize it. Some principles deserve deeper commentary:
- “Explicit is better than implicit.” Avoid magic. Don’t rely on side effects of import order, don’t monkeypatch unless absolutely necessary, and don’t hide control flow behind clever metaprogramming when a plain function will do.
- “Flat is better than nested.” Prefer early returns (guard clauses) over deeply nested
if/elseblocks. - “Errors should never pass silently.” Never use bare
except:to swallow errors. - “There should be one — and preferably only one — obvious way to do it.” When multiple team members solve the same problem five different ways, that’s a signal to establish conventions (via linters and code review), not a badge of expressiveness.
# Bad: nested conditionals
def process(order):
if order is not None:
if order.is_valid():
if order.total > 0:
return charge(order)
else:
return None
else:
return None
else:
return None
# Good: guard clauses (flat, explicit, readable)
def process(order):
if order is None:
return None
if not order.is_valid():
return None
if order.total <= 0:
return None
return charge(order)
2. Code Style & PEP 8
- 4 spaces per indentation level, never tabs.
- Max line length: 79–99 characters (Black defaults to 88).
- Two blank lines between top-level functions/classes, one blank line between methods.
- Use
snake_casefor functions/variables,PascalCasefor classes,UPPER_SNAKE_CASEfor constants. - Imports ordered: standard library → third-party → local, each group separated by a blank line, alphabetized within each group (isort automates this).
- Use
blackfor auto-formatting so style is never a debate topic in code review.
# Import order example
import os
import sys
from collections import defaultdict
import numpy as np
import requests
from myapp.core import settings
from myapp.utils import helpers
- Prefer f-strings over
.format()or%formatting:
name = "Ada"
# Good
greeting = f"Hello, {name}!"
# Avoid
greeting = "Hello, {}!".format(name)
greeting = "Hello, %s!" % name
3. Naming Conventions
| Element | Convention | Example |
|---|---|---|
| Module | snake_case, short | data_loader.py |
| Package | snake_case, no underscores ideally | mypackage |
| Class | PascalCase | HttpClient |
| Exception class | PascalCase + Error suffix | ValidationError |
| Function/method | snake_case, verb phrase | calculate_total() |
| Variable | snake_case, noun | user_count |
| Constant | UPPER_SNAKE_CASE | MAX_RETRIES = 3 |
| “Private” attribute | leading underscore | self._cache |
| Name-mangled attribute | leading double underscore | self.__internal |
| Unused/throwaway variable | single underscore | for _ in range(5): |
Avoid single-letter names except in tight, obvious scopes (i, j in a loop; x, y in math). Avoid ambiguous abbreviations — cfg is fine, cfgr is not.
4. Type Hints & Static Typing
Type hints are not optional in professional codebases anymore. They serve as documentation, enable IDE autocompletion, and catch bugs via mypy/pyright before runtime.
from __future__ import annotations
from typing import Optional, Union, Callable, Iterable
def find_user(user_id: int, *, cache: dict[int, "User"] | None = None) -> "User" | None:
...
def apply(fn: Callable[[int, int], int], values: Iterable[tuple[int, int]]) -> list[int]:
return [fn(a, b) for a, b in values]
Key practices:
- Use built-in generics (
list[int],dict[str, int]) instead oftyping.List/typing.Dictsince Python 3.9+. - Use
X | Noneinstead ofOptional[X]since Python 3.10+ (withfrom __future__ import annotationsfor earlier 3.x support). - Use
Protocolfor structural typing (“duck typing” made explicit) instead of forcing inheritance:
from typing import Protocol
class SupportsClose(Protocol):
def close(self) -> None: ...
def cleanup(resource: SupportsClose) -> None:
resource.close()
- Use
TypedDictfor structured dict payloads (e.g., JSON API responses):
from typing import TypedDict
class UserPayload(TypedDict):
id: int
name: str
email: str
- Use
Literalto constrain values:
from typing import Literal
def set_mode(mode: Literal["r", "w", "a"]) -> None: ...
- Use
TypeVarandGenericfor reusable generic containers:
from typing import TypeVar, Generic
T = TypeVar("T")
class Stack(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
- Run
mypy --strict(orpyright) in CI. Type hints without enforcement quickly rot.
5. Data Structures — Idiomatic Usage
Choosing the right container
| Need | Use |
|---|---|
| Ordered, mutable sequence | list |
| Ordered, immutable sequence | tuple |
| Unique unordered items, fast membership test | set |
| Key-value mapping | dict |
| FIFO/LIFO queue with O(1) ends | collections.deque |
| Counting occurrences | collections.Counter |
| Default values on missing keys | collections.defaultdict |
| Ordered mapping with move-to-end | collections.OrderedDict (mostly obsolete since dicts are ordered in 3.7+, but still useful for move_to_end) |
| Lightweight immutable record | NamedTuple or dataclass(frozen=True) |
| Merge multiple dict-like objects lazily | collections.ChainMap |
from collections import Counter, defaultdict, deque
# Counting
word_counts = Counter("the quick brown fox jumps over the lazy dog".split())
print(word_counts.most_common(2))
# Grouping
groups: defaultdict[str, list[int]] = defaultdict(list)
for n in range(10):
groups["even" if n % 2 == 0 else "odd"].append(n)
# Sliding window / queue
window = deque(maxlen=3)
for x in range(10):
window.append(x)
Sets for membership tests
# Bad: O(n) membership check
valid_ids = [1, 2, 3, 4, 5]
if user_id in valid_ids:
...
# Good: O(1) average
valid_ids = {1, 2, 3, 4, 5}
if user_id in valid_ids:
...
Unpacking
first, *rest = [1, 2, 3, 4]
*init, last = [1, 2, 3, 4]
a, (b, c) = 1, (2, 3)
# Swap without a temp variable
a, b = b, a
dict.get, setdefault, and the walrus operator
value = data.get("key", "default")
data.setdefault("key", []).append(item)
# Walrus operator (3.8+) avoids double computation
if (n := len(data)) > 10:
print(f"Too many items: {n}")
6. Functions: Design & Best Practices
- Keep functions small and single-purpose (Single Responsibility Principle applies to functions too).
- Prefer pure functions (no side effects) where possible — easier to test and reason about.
- Never use mutable default arguments:
# Bad — the list is shared across calls!
def append_item(item, items=[]):
items.append(item)
return items
# Good
def append_item(item, items: list | None = None):
if items is None:
items = []
items.append(item)
return items
- Use keyword-only arguments for clarity when a function has many parameters:
def create_user(*, name: str, email: str, is_admin: bool = False) -> "User":
...
create_user(name="Ada", email="ada@example.com", is_admin=True)
- Use positional-only parameters (
/) when argument names are implementation details:
def add(a: int, b: int, /) -> int:
return a + b
- Prefer returning early over accumulating state through nested branches.
- Avoid functions with more than ~4 parameters; use a dataclass or config object instead.
- Document with docstrings (Google or NumPy style) — not just for humans but for tools like Sphinx.
def calculate_discount(price: float, percentage: float) -> float:
"""Calculate the discounted price.
Args:
price: The original price.
percentage: Discount percentage (0-100).
Returns:
The price after discount is applied.
Raises:
ValueError: If percentage is not between 0 and 100.
"""
if not 0 <= percentage <= 100:
raise ValueError("percentage must be between 0 and 100")
return price * (1 - percentage / 100)
7. Comprehensions & Generator Expressions
Comprehensions are more Pythonic and often faster than manual loops with .append().
# List comprehension
squares = [x**2 for x in range(10)]
# Set comprehension
unique_lengths = {len(word) for word in ["a", "bb", "ccc", "dd"]}
# Dict comprehension
name_to_len = {name: len(name) for name in ["Ada", "Grace", "Alan"]}
# Nested comprehension (flatten a matrix)
matrix = [[1, 2], [3, 4]]
flat = [x for row in matrix for x in row]
# Conditional comprehension
evens = [x for x in range(20) if x % 2 == 0]
# Generator expression — lazy, memory-efficient
total = sum(x**2 for x in range(1_000_000))
Rule of thumb: if the comprehension needs more than 2 levels of nesting or more than one if, refactor into a regular loop or a named helper function for readability.
# Too clever — hard to read
result = [y for x in data if x > 0 for y in transform(x) if y is not None]
# Better
def transform_positive(data):
for x in data:
if x <= 0:
continue
for y in transform(x):
if y is not None:
yield y
8. Iterators & Generators
Generators enable lazy evaluation, drastically reducing memory footprint for large or infinite sequences.
def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
fib = fibonacci()
first_ten = [next(fib) for _ in range(10)]
yield from for delegation
def flatten(nested):
for item in nested:
if isinstance(item, list):
yield from flatten(item)
else:
yield item
list(flatten([1, [2, 3, [4, 5]], 6])) # [1, 2, 3, 4, 5, 6]
Custom iterator protocol
class Range:
def __init__(self, start: int, stop: int) -> None:
self.current = start
self.stop = stop
def __iter__(self) -> "Range":
return self
def __next__(self) -> int:
if self.current >= self.stop:
raise StopIteration
value = self.current
self.current += 1
return value
Prefer generators over building a full list when you only need to iterate once — especially for I/O pipelines and streaming data.
9. Context Managers
Always use with for resource management (files, locks, connections, transactions).
with open("data.txt") as f:
content = f.read()
# Multiple context managers
with open("in.txt") as fin, open("out.txt", "w") as fout:
fout.write(fin.read())
Writing your own context manager (class-based)
class Timer:
def __enter__(self):
import time
self.start = time.perf_counter()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
import time
self.elapsed = time.perf_counter() - self.start
return False # propagate exceptions
Writing your own context manager (function-based, contextlib)
from contextlib import contextmanager
@contextmanager
def timer():
import time
start = time.perf_counter()
try:
yield
finally:
print(f"Elapsed: {time.perf_counter() - start:.4f}s")
with timer():
do_expensive_work()
contextlib.suppress instead of try/except/pass
from contextlib import suppress
with suppress(FileNotFoundError):
os.remove("maybe_missing.txt")
ExitStack for a dynamic number of context managers
from contextlib import ExitStack
with ExitStack() as stack:
files = [stack.enter_context(open(fname)) for fname in filenames]
# all files closed automatically on exit, even on exception
10. Decorators
Decorators wrap functions to add behavior without modifying their source — the classic Open/Closed Principle in action.
import functools
import time
def timed(func):
@functools.wraps(func) # preserves __name__, __doc__, etc.
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__} took {time.perf_counter() - start:.4f}s")
return result
return wrapper
@timed
def slow_function():
time.sleep(1)
Decorators with arguments
def retry(times: int = 3, exceptions: tuple = (Exception,)):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exc = None
for attempt in range(times):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exc = e
raise last_exc
return wrapper
return decorator
@retry(times=5, exceptions=(ConnectionError,))
def fetch_data():
...
Class-based decorators & caching
from functools import lru_cache, cache
@lru_cache(maxsize=128)
def fib(n: int) -> int:
return n if n < 2 else fib(n - 1) + fib(n - 2)
@cache # unbounded cache, Python 3.9+
def expensive_lookup(key: str) -> str:
...
Always use functools.wraps — omitting it breaks introspection, debugging tools, and documentation generators.
11. OOP in Python: Classes Done Right
- Favor composition over inheritance.
- Use
__slots__for memory efficiency in classes with many instances and fixed attributes:
class Point:
__slots__ = ("x", "y")
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
- Implement
__repr__on every class you write (for debugging); implement__eq__,__hash__when identity-by-value matters.
class Point:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
def __repr__(self) -> str:
return f"Point(x={self.x!r}, y={self.y!r})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, Point):
return NotImplemented
return (self.x, self.y) == (other.x, other.y)
def __hash__(self) -> int:
return hash((self.x, self.y))
- Use properties instead of manual getter/setter methods:
class Temperature:
def __init__(self, celsius: float) -> None:
self._celsius = celsius
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
if value < -273.15:
raise ValueError("Below absolute zero")
self._celsius = value
@property
def fahrenheit(self) -> float:
return self._celsius * 9 / 5 + 32
- Use
@classmethodfor alternative constructors,@staticmethodfor utility functions with no relation to instance state:
class User:
def __init__(self, name: str, email: str) -> None:
self.name = name
self.email = email
@classmethod
def from_dict(cls, data: dict) -> "User":
return cls(name=data["name"], email=data["email"])
@staticmethod
def is_valid_email(email: str) -> bool:
return "@" in email
- Use Abstract Base Classes (
abc) to define interfaces:
from abc import ABC, abstractmethod
class PaymentProcessor(ABC):
@abstractmethod
def charge(self, amount: float) -> bool: ...
class StripeProcessor(PaymentProcessor):
def charge(self, amount: float) -> bool:
...
- Use mixins carefully — keep them small, focused, and side-effect free; document the MRO (Method Resolution Order) implications.
12. Dataclasses, NamedTuples & Attrs
Prefer @dataclass over manually writing __init__, __repr__, __eq__.
from dataclasses import dataclass, field
@dataclass(frozen=True, slots=True)
class Point:
x: float
y: float
@dataclass
class Order:
items: list[str] = field(default_factory=list)
total: float = 0.0
def add_item(self, item: str, price: float) -> None:
self.items.append(item)
self.total += price
frozen=Truefor immutability (and hashability, if all fields are hashable).slots=True(3.10+) to reduce memory footprint and prevent accidental attribute creation.- Use
field(default_factory=...)for mutable defaults — neverfield(default=[]).
NamedTuple for lightweight, tuple-like immutable records that also support unpacking:
from typing import NamedTuple
class Point(NamedTuple):
x: float
y: float
p = Point(1.0, 2.0)
x, y = p # tuple unpacking works
attrs (third-party, predates dataclasses) is still preferred by some teams for advanced validators/converters, but dataclasses + pydantic (for validation) covers most modern needs.
13. Error Handling & Exceptions
- Catch specific exceptions, never bare
except:.
# Bad
try:
risky()
except:
pass
# Good
try:
risky()
except (ConnectionError, TimeoutError) as e:
logger.warning("Network issue: %s", e)
raise
- Create a custom exception hierarchy for your domain:
class AppError(Exception):
"""Base exception for this application."""
class ValidationError(AppError):
"""Raised when input validation fails."""
class NotFoundError(AppError):
"""Raised when a resource is not found."""
- Use
raise ... from errto preserve exception chains when translating exceptions:
try:
parse(data)
except ValueError as e:
raise ValidationError("Invalid input") from e
- Use
elseandfinallyclauses appropriately:
try:
conn = connect()
except ConnectionError:
logger.error("Could not connect")
else:
# runs only if no exception was raised
process(conn)
finally:
# always runs — cleanup
conn.close()
- Exceptions are for exceptional cases, not control flow — but “Easier to Ask Forgiveness than Permission” (EAFP) is still the Pythonic default over “Look Before You Leap” (LBYL) for things like dict access:
# Pythonic (EAFP)
try:
value = my_dict["key"]
except KeyError:
value = default
# Less Pythonic (LBYL) — race condition prone in concurrent code
if "key" in my_dict:
value = my_dict["key"]
else:
value = default
- Use
ExceptionGroupandexcept*(3.11+) when handling multiple concurrent errors (e.g., fromasyncio.TaskGroup).
14. Modules, Packages & Project Structure
Recommended src-layout for a modern Python package:
myproject/
├── pyproject.toml
├── README.md
├── LICENSE
├── src/
│ └── mypackage/
│ ├── __init__.py
│ ├── core.py
│ ├── models/
│ │ ├── __init__.py
│ │ └── user.py
│ └── utils/
│ ├── __init__.py
│ └── helpers.py
├── tests/
│ ├── __init__.py
│ ├── test_core.py
│ └── conftest.py
└── docs/
- Use
pyproject.toml(PEP 621) exclusively —setup.py/setup.cfgare legacy. - The
src/layout prevents accidentally importing your package from the working directory instead of the installed version (a classic testing footgun). - Keep
__init__.pyfiles thin — re-export the public API, don’t put logic there. - Use relative imports within a package, absolute imports across packages:
# inside mypackage/models/user.py
from ..utils.helpers import normalize_email # relative, within package
from mypackage.core import Settings # absolute, still fine
- Avoid circular imports by structuring dependencies as a DAG; if two modules need each other, extract shared code into a third module.
15. Concurrency: Threading, Multiprocessing, Asyncio
When to use what
| Workload | Tool |
|---|---|
| I/O-bound (network, disk, DB) — many overlapping waits | asyncio or threading |
| CPU-bound (heavy computation) | multiprocessing or native extensions (NumPy, Cython) |
| Simple parallel I/O tasks without full async rewrite | concurrent.futures.ThreadPoolExecutor |
| Simple parallel CPU tasks | concurrent.futures.ProcessPoolExecutor |
The GIL (Global Interpreter Lock) means threads don’t give CPU parallelism for pure-Python code — only I/O-bound work benefits from threading unless you’re on a free-threaded (no-GIL) build.
import asyncio
import aiohttp
async def fetch(session: aiohttp.ClientSession, url: str) -> str:
async with session.get(url) as resp:
return await resp.text()
async def main(urls: list[str]) -> list[str]:
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in urls]
return await asyncio.gather(*tasks)
asyncio.run(main(["https://example.com"] * 10))
asyncio.TaskGroup (3.11+) for structured concurrency
async def main():
async with asyncio.TaskGroup() as tg:
task1 = tg.create_task(fetch(url1))
task2 = tg.create_task(fetch(url2))
# both tasks guaranteed complete here; exceptions propagate as ExceptionGroup
ProcessPoolExecutor for CPU-bound parallelism
from concurrent.futures import ProcessPoolExecutor
def heavy_computation(n: int) -> int:
return sum(i * i for i in range(n))
with ProcessPoolExecutor() as executor:
results = list(executor.map(heavy_computation, [10_000_000] * 4))
Always protect multiprocessing entry points with if __name__ == "__main__": on Windows/spawn-based platforms.
16. Performance Optimization
- Measure before optimizing — use
cProfile,py-spy, orline_profiler. - Prefer built-in functions and the standard library — they’re implemented in C.
- Use local variable references instead of repeated attribute lookups inside hot loops.
- Use
arrayor NumPy for large numeric datasets instead of lists. - Use
__slots__to reduce per-instance memory overhead. - String concatenation in a loop: use
"".join(parts)instead of+=.
# Slow: O(n^2) due to repeated string copies
result = ""
for s in strings:
result += s
# Fast: O(n)
result = "".join(strings)
- Cache expensive pure computations with
functools.lru_cache. - Use generators to avoid materializing large intermediate lists.
- Consider
Cython,Numba, or rewriting hot paths in Rust (viaPyO3) for genuinely CPU-bound bottlenecks. - Use
timeitfor micro-benchmarks:
import timeit
timeit.timeit("'-'.join(str(n) for n in range(100))", number=10000)
17. Testing Best Practices
- Use
pytestoverunittestfor its simpler assertion syntax and powerful fixtures.
def test_calculate_discount():
assert calculate_discount(100, 10) == 90
def test_calculate_discount_invalid_percentage():
import pytest
with pytest.raises(ValueError):
calculate_discount(100, 150)
- Use fixtures for setup/teardown and dependency injection:
import pytest
@pytest.fixture
def sample_user():
return User(name="Ada", email="ada@example.com")
def test_user_email(sample_user):
assert sample_user.email == "ada@example.com"
- Parametrize tests instead of duplicating them:
@pytest.mark.parametrize("price,pct,expected", [
(100, 0, 100),
(100, 50, 50),
(200, 25, 150),
])
def test_discounts(price, pct, expected):
assert calculate_discount(price, pct) == expected
- Mock external dependencies (network, DB, filesystem) — never hit real services in unit tests.
from unittest.mock import patch
@patch("myapp.services.requests.get")
def test_fetch_user(mock_get):
mock_get.return_value.json.return_value = {"id": 1, "name": "Ada"}
result = fetch_user(1)
assert result["name"] == "Ada"
- Aim for the testing pyramid: many fast unit tests, fewer integration tests, very few end-to-end tests.
- Use
coverage.pyto track coverage, but don’t chase 100% blindly — focus on critical paths and edge cases. - Keep tests deterministic: no reliance on wall-clock time, random seeds, or network availability without mocking.
18. Logging
Never use print() for anything beyond throwaway scripts. Use the logging module.
import logging
logger = logging.getLogger(__name__)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
def process_order(order_id: int) -> None:
logger.info("Processing order %s", order_id)
try:
...
except Exception:
logger.exception("Failed to process order %s", order_id)
raise
- Use
%slazy formatting instead of f-strings in log calls — avoids formatting cost when the log level is disabled. - Use
logger.exception()inside anexceptblock to automatically include the traceback. - Configure structured (JSON) logging for production services to integrate with log aggregators.
- Never log secrets, passwords, tokens, or PII.
19. Security Best Practices
- Never use
eval()orexec()on untrusted input. - Use
secretsmodule (notrandom) for tokens, passwords, and cryptographic purposes:
import secrets
token = secrets.token_urlsafe(32)
- Never hardcode credentials — use environment variables or a secrets manager.
- Validate and sanitize all external input (use
pydanticfor structured validation). - Use parameterized queries — never string-format SQL:
# Bad — SQL injection risk
cursor.execute(f"SELECT * FROM users WHERE name = '{name}'")
# Good — parameterized
cursor.execute("SELECT * FROM users WHERE name = ?", (name,))
- Pin dependency versions and regularly run
pip-auditorsafetyto catch known vulnerabilities. - Avoid
picklefor untrusted data — it can execute arbitrary code on deserialization; preferjsonormsgpack.
20. Design Patterns in Python
Python’s dynamic typing, first-class functions, and duck typing mean many classic GoF patterns are simpler or unnecessary — but understanding them (and their Pythonic equivalents) is essential.
Creational Patterns
Singleton
class Singleton:
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
Pythonic alternative: a module itself is a singleton — module-level state is simpler and more idiomatic than a Singleton class in most cases.
Factory Method
from abc import ABC, abstractmethod
class Notifier(ABC):
@abstractmethod
def send(self, message: str) -> None: ...
class EmailNotifier(Notifier):
def send(self, message: str) -> None:
print(f"Email: {message}")
class SmsNotifier(Notifier):
def send(self, message: str) -> None:
print(f"SMS: {message}")
def notifier_factory(kind: str) -> Notifier:
return {"email": EmailNotifier, "sms": SmsNotifier}[kind]()
Abstract Factory
class GUIFactory(ABC):
@abstractmethod
def create_button(self) -> "Button": ...
@abstractmethod
def create_checkbox(self) -> "Checkbox": ...
class WindowsFactory(GUIFactory):
def create_button(self): return WindowsButton()
def create_checkbox(self): return WindowsCheckbox()
class MacFactory(GUIFactory):
def create_button(self): return MacButton()
def create_checkbox(self): return MacCheckbox()
Builder
class RequestBuilder:
def __init__(self) -> None:
self._url = ""
self._headers: dict[str, str] = {}
self._method = "GET"
def url(self, url: str) -> "RequestBuilder":
self._url = url
return self
def header(self, key: str, value: str) -> "RequestBuilder":
self._headers[key] = value
return self
def method(self, method: str) -> "RequestBuilder":
self._method = method
return self
def build(self) -> dict:
return {"url": self._url, "headers": self._headers, "method": self._method}
request = (
RequestBuilder()
.url("https://api.example.com")
.header("Authorization", "Bearer token")
.method("POST")
.build()
)
Prototype
import copy
class Prototype:
def clone(self):
return copy.deepcopy(self)
Structural Patterns
Adapter
class OldPrinter:
def print_old(self, text: str) -> None:
print(f"[OLD] {text}")
class NewPrinterInterface(ABC):
@abstractmethod
def print(self, text: str) -> None: ...
class PrinterAdapter(NewPrinterInterface):
def __init__(self, old_printer: OldPrinter) -> None:
self._old_printer = old_printer
def print(self, text: str) -> None:
self._old_printer.print_old(text)
Decorator (structural, not @decorator syntax)
class Coffee(ABC):
@abstractmethod
def cost(self) -> float: ...
class SimpleCoffee(Coffee):
def cost(self) -> float:
return 2.0
class MilkDecorator(Coffee):
def __init__(self, coffee: Coffee) -> None:
self._coffee = coffee
def cost(self) -> float:
return self._coffee.cost() + 0.5
coffee = MilkDecorator(SimpleCoffee())
print(coffee.cost()) # 2.5
Facade
class CPU:
def freeze(self): ...
def jump(self, position): ...
def execute(self): ...
class Memory:
def load(self, position, data): ...
class ComputerFacade:
def __init__(self):
self.cpu = CPU()
self.memory = Memory()
def start(self):
self.cpu.freeze()
self.memory.load(0, "boot_data")
self.cpu.jump(0)
self.cpu.execute()
Proxy
class RealImage:
def __init__(self, filename: str) -> None:
self.filename = filename
self._load()
def _load(self) -> None:
print(f"Loading {self.filename}")
def display(self) -> None:
print(f"Displaying {self.filename}")
class ImageProxy:
def __init__(self, filename: str) -> None:
self.filename = filename
self._real_image = None
def display(self) -> None:
if self._real_image is None:
self._real_image = RealImage(self.filename) # lazy load
self._real_image.display()
Composite
class Component(ABC):
@abstractmethod
def render(self, indent: int = 0) -> str: ...
class Leaf(Component):
def __init__(self, name: str) -> None:
self.name = name
def render(self, indent: int = 0) -> str:
return " " * indent + self.name
class Composite(Component):
def __init__(self, name: str) -> None:
self.name = name
self.children: list[Component] = []
def add(self, component: Component) -> None:
self.children.append(component)
def render(self, indent: int = 0) -> str:
lines = [" " * indent + self.name]
for child in self.children:
lines.append(child.render(indent + 1))
return "\n".join(lines)
Behavioral Patterns
Observer
class Subject:
def __init__(self) -> None:
self._observers: list[Callable] = []
def subscribe(self, observer: Callable) -> None:
self._observers.append(observer)
def notify(self, *args, **kwargs) -> None:
for observer in self._observers:
observer(*args, **kwargs)
subject = Subject()
subject.subscribe(lambda event: print(f"Received: {event}"))
subject.notify("user_created")
Strategy
class SortStrategy(ABC):
@abstractmethod
def sort(self, data: list) -> list: ...
class QuickSort(SortStrategy):
def sort(self, data: list) -> list:
return sorted(data) # simplified
class Sorter:
def __init__(self, strategy: SortStrategy) -> None:
self._strategy = strategy
def sort(self, data: list) -> list:
return self._strategy.sort(data)
Pythonic alternative: pass a function directly instead of wrapping it in a class:
def sort_data(data: list, strategy: Callable[[list], list] = sorted) -> list:
return strategy(data)
Command
class Command(ABC):
@abstractmethod
def execute(self) -> None: ...
@abstractmethod
def undo(self) -> None: ...
class AddTextCommand(Command):
def __init__(self, document: list[str], text: str) -> None:
self.document = document
self.text = text
def execute(self) -> None:
self.document.append(self.text)
def undo(self) -> None:
self.document.remove(self.text)
State
class State(ABC):
@abstractmethod
def handle(self, context: "TrafficLight") -> None: ...
class RedState(State):
def handle(self, context):
print("Red -> Green")
context.state = GreenState()
class GreenState(State):
def handle(self, context):
print("Green -> Red")
context.state = RedState()
class TrafficLight:
def __init__(self):
self.state: State = RedState()
def change(self):
self.state.handle(self)
Template Method
class DataProcessor(ABC):
def process(self) -> None:
self.load()
self.transform()
self.save()
@abstractmethod
def load(self) -> None: ...
@abstractmethod
def transform(self) -> None: ...
@abstractmethod
def save(self) -> None: ...
class CsvProcessor(DataProcessor):
def load(self): print("Loading CSV")
def transform(self): print("Transforming CSV")
def save(self): print("Saving CSV")
Chain of Responsibility
class Handler(ABC):
def __init__(self) -> None:
self._next: Handler | None = None
def set_next(self, handler: "Handler") -> "Handler":
self._next = handler
return handler
def handle(self, request) -> None:
if self._next:
self._next.handle(request)
class AuthHandler(Handler):
def handle(self, request):
if not request.get("authenticated"):
print("Auth failed")
return
super().handle(request)
Iterator (built into the language via __iter__/__next__, see Section 8)
21. Common Pitfalls & Anti-Patterns
- Mutable default arguments (covered in Section 6) — the #1 Python footgun.
- Late binding closures in loops:
# Bug: all lambdas capture the same `i` (final value)
funcs = [lambda: i for i in range(5)]
print([f() for f in funcs]) # [4, 4, 4, 4, 4]
# Fix: default argument captures value at definition time
funcs = [lambda i=i: i for i in range(5)]
print([f() for f in funcs]) # [0, 1, 2, 3, 4]
- Modifying a list while iterating over it:
# Bug: skips elements
for item in my_list:
if condition(item):
my_list.remove(item)
# Fix: iterate over a copy, or build a new list
my_list = [item for item in my_list if not condition(item)]
- Comparing with
isinstead of==for value equality (ischecks identity, not value — only safe forNone,True,False, and sentinel objects). - Catching
Exceptiontoo broadly, hiding real bugs. - Overusing inheritance where composition would be simpler and more flexible.
- Circular imports from poor module structure.
- Using
type()instead ofisinstance()for type checks, which breaks polymorphism:
# Bad
if type(obj) == list:
...
# Good
if isinstance(obj, list):
...
- Ignoring context managers for files/locks/connections, risking resource leaks.
- Global mutable state making code hard to test and reason about in concurrent contexts.
- String-based dispatch instead of proper polymorphism/enums:
# Fragile
if shape_type == "circle":
...
elif shape_type == "square":
...
# Better: Enum + dispatch dict, or polymorphism via classes
from enum import Enum, auto
class ShapeType(Enum):
CIRCLE = auto()
SQUARE = auto()
22. Tooling Ecosystem
| Purpose | Tool |
|---|---|
| Formatting | black, ruff format |
| Linting | ruff, flake8, pylint |
| Static typing | mypy, pyright |
| Import sorting | isort (or ruff’s built-in) |
| Testing | pytest, hypothesis (property-based testing) |
| Coverage | coverage.py |
| Dependency management | poetry, uv, pip-tools |
| Security scanning | bandit, pip-audit |
| Pre-commit hooks | pre-commit |
| Documentation | sphinx, mkdocs |
| Data validation | pydantic |
| Task runner | nox, tox, make |
A minimal pyproject.toml with tooling configured:
[project]
name = "mypackage"
version = "0.1.0"
requires-python = ">=3.11"
[tool.ruff]
line-length = 88
select = ["E", "F", "I", "UP", "B"]
[tool.mypy]
strict = true
[tool.pytest.ini_options]
testpaths = ["tests"]
23. Final Checklist
- Code formatted with
black/ruff format, linted withruff/flake8. - Type hints on all public functions;
mypy --strictpasses. - No bare
except:; custom exception hierarchy for domain errors. - No mutable default arguments.
- Context managers used for all resource management.
- Logging via
loggingmodule, neverprint()in library code. - Tests written with
pytest, parametrized, mocked external dependencies. - Docstrings on public API following a consistent style (Google/NumPy).
-
src/layout withpyproject.toml. - Dependencies pinned; security-scanned regularly.
- No use of
eval/exec/pickleon untrusted data. - Design patterns applied where they reduce complexity, not for their own sake — remember: “Python already has first-class functions; you often don’t need the class-based GoF version.”
This document reflects modern Python (3.10+) idioms as of early 2026. Language features continue to evolve — always check the changelog of the Python version you target.