The Complete Guide to Python Best Practices, Tips & Design Patterns

A principal-engineer-level reference for writing idiomatic, maintainable, performant, and safe Python.

🌱 Seedling·created: ·category:Python

A principal-engineer-level reference for writing idiomatic, maintainable, performant, and safe Python.


Table of Contents

  1. Philosophy & The Zen of Python
  2. Code Style & PEP 8
  3. Naming Conventions
  4. Type Hints & Static Typing
  5. Data Structures — Idiomatic Usage
  6. Functions: Design & Best Practices
  7. Comprehensions & Generator Expressions
  8. Iterators & Generators
  9. Context Managers
  10. Decorators
  11. OOP in Python: Classes Done Right
  12. Dataclasses, NamedTuples & Attrs
  13. Error Handling & Exceptions
  14. Modules, Packages & Project Structure
  15. Concurrency: Threading, Multiprocessing, Asyncio
  16. Performance Optimization
  17. Testing Best Practices
  18. Logging
  19. Security Best Practices
  20. Design Patterns in Python
  21. Common Pitfalls & Anti-Patterns
  22. Tooling Ecosystem
  23. 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/else blocks.
  • “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_case for functions/variables, PascalCase for classes, UPPER_SNAKE_CASE for constants.
  • Imports ordered: standard library → third-party → local, each group separated by a blank line, alphabetized within each group (isort automates this).
  • Use black for 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

ElementConventionExample
Modulesnake_case, shortdata_loader.py
Packagesnake_case, no underscores ideallymypackage
ClassPascalCaseHttpClient
Exception classPascalCase + Error suffixValidationError
Function/methodsnake_case, verb phrasecalculate_total()
Variablesnake_case, nounuser_count
ConstantUPPER_SNAKE_CASEMAX_RETRIES = 3
“Private” attributeleading underscoreself._cache
Name-mangled attributeleading double underscoreself.__internal
Unused/throwaway variablesingle underscorefor _ 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 of typing.List/typing.Dict since Python 3.9+.
  • Use X | None instead of Optional[X] since Python 3.10+ (with from __future__ import annotations for earlier 3.x support).
  • Use Protocol for 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 TypedDict for structured dict payloads (e.g., JSON API responses):
from typing import TypedDict

class UserPayload(TypedDict):
    id: int
    name: str
    email: str
  • Use Literal to constrain values:
from typing import Literal

def set_mode(mode: Literal["r", "w", "a"]) -> None: ...
  • Use TypeVar and Generic for 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 (or pyright) in CI. Type hints without enforcement quickly rot.

5. Data Structures — Idiomatic Usage

Choosing the right container

NeedUse
Ordered, mutable sequencelist
Ordered, immutable sequencetuple
Unique unordered items, fast membership testset
Key-value mappingdict
FIFO/LIFO queue with O(1) endscollections.deque
Counting occurrencescollections.Counter
Default values on missing keyscollections.defaultdict
Ordered mapping with move-to-endcollections.OrderedDict (mostly obsolete since dicts are ordered in 3.7+, but still useful for move_to_end)
Lightweight immutable recordNamedTuple or dataclass(frozen=True)
Merge multiple dict-like objects lazilycollections.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 @classmethod for alternative constructors, @staticmethod for 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=True for 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 — never field(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 err to preserve exception chains when translating exceptions:
try:
    parse(data)
except ValueError as e:
    raise ValidationError("Invalid input") from e
  • Use else and finally clauses 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 ExceptionGroup and except* (3.11+) when handling multiple concurrent errors (e.g., from asyncio.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.cfg are legacy.
  • The src/ layout prevents accidentally importing your package from the working directory instead of the installed version (a classic testing footgun).
  • Keep __init__.py files 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

WorkloadTool
I/O-bound (network, disk, DB) — many overlapping waitsasyncio or threading
CPU-bound (heavy computation)multiprocessing or native extensions (NumPy, Cython)
Simple parallel I/O tasks without full async rewriteconcurrent.futures.ThreadPoolExecutor
Simple parallel CPU tasksconcurrent.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

  1. Measure before optimizing — use cProfile, py-spy, or line_profiler.
  2. Prefer built-in functions and the standard library — they’re implemented in C.
  3. Use local variable references instead of repeated attribute lookups inside hot loops.
  4. Use array or NumPy for large numeric datasets instead of lists.
  5. Use __slots__ to reduce per-instance memory overhead.
  6. 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)
  1. Cache expensive pure computations with functools.lru_cache.
  2. Use generators to avoid materializing large intermediate lists.
  3. Consider Cython, Numba, or rewriting hot paths in Rust (via PyO3) for genuinely CPU-bound bottlenecks.
  4. Use timeit for micro-benchmarks:
import timeit
timeit.timeit("'-'.join(str(n) for n in range(100))", number=10000)

17. Testing Best Practices

  • Use pytest over unittest for 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.py to 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 %s lazy formatting instead of f-strings in log calls — avoids formatting cost when the log level is disabled.
  • Use logger.exception() inside an except block 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() or exec() on untrusted input.
  • Use secrets module (not random) 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 pydantic for 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-audit or safety to catch known vulnerabilities.
  • Avoid pickle for untrusted data — it can execute arbitrary code on deserialization; prefer json or msgpack.

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

  1. Mutable default arguments (covered in Section 6) — the #1 Python footgun.
  2. 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]
  1. 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)]
  1. Comparing with is instead of == for value equality (is checks identity, not value — only safe for None, True, False, and sentinel objects).
  2. Catching Exception too broadly, hiding real bugs.
  3. Overusing inheritance where composition would be simpler and more flexible.
  4. Circular imports from poor module structure.
  5. Using type() instead of isinstance() for type checks, which breaks polymorphism:
# Bad
if type(obj) == list:
    ...

# Good
if isinstance(obj, list):
    ...
  1. Ignoring context managers for files/locks/connections, risking resource leaks.
  2. Global mutable state making code hard to test and reason about in concurrent contexts.
  3. 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

PurposeTool
Formattingblack, ruff format
Lintingruff, flake8, pylint
Static typingmypy, pyright
Import sortingisort (or ruff’s built-in)
Testingpytest, hypothesis (property-based testing)
Coveragecoverage.py
Dependency managementpoetry, uv, pip-tools
Security scanningbandit, pip-audit
Pre-commit hookspre-commit
Documentationsphinx, mkdocs
Data validationpydantic
Task runnernox, 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 with ruff/flake8.
  • Type hints on all public functions; mypy --strict passes.
  • No bare except:; custom exception hierarchy for domain errors.
  • No mutable default arguments.
  • Context managers used for all resource management.
  • Logging via logging module, never print() in library code.
  • Tests written with pytest, parametrized, mocked external dependencies.
  • Docstrings on public API following a consistent style (Google/NumPy).
  • src/ layout with pyproject.toml.
  • Dependencies pinned; security-scanned regularly.
  • No use of eval/exec/pickle on 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.

This note is part of the Digital Garden — a collection of connected, evolving thoughts.