|$ curl https://forge-ai.dev/api/markdown?path=docs/python/best-practices
$cat docs/python-—-best-practices-&-patterns.md
updated Recently·20 min read·published

Python — Best Practices & Patterns

PythonAdvanced
Introduction

Python prioritizes readability and simplicity, but writing truly idiomatic, maintainable Python requires discipline. This guide distills community-agreed best practices — from PEP 8 and naming conventions to project structure, design patterns, and tooling — giving you a reference for writing Python that stands up to code review and scales across teams.

These practices are not rigid rules. They are heuristics refined by thousands of Python developers over decades. Understand the reasoning behind each one, and apply judgment when deviating.

zen_of_python.py
Python
1# The Zen of Python (import this)
2#
3# Beautiful is better than ugly.
4# Explicit is better than implicit.
5# Simple is better than complex.
6# Complex is better than complicated.
7# Flat is better than nested.
8# Sparse is better than dense.
9# Readability counts.
10# Special cases aren't special enough to break the rules.
11# Although practicality beats purity.
12# Errors should never pass silently.
13# Unless explicitly silenced.
14# In the face of ambiguity, refuse the temptation to guess.
15# There should be one — and preferably only one — obvious way to do it.
16# Although that way may not be obvious at first unless you're Dutch.
17# Now is better than never.
18# Although never is often better than *right* now.
19# If the implementation is hard to explain, it's a bad idea.
20# If the implementation is easy to explain, it may be a good idea.
21# Namespaces are one honking great idea — let's do more of those!
PEP 8 — Style Guide

PEP 8 is the official style guide for Python code. While tools like ruff or black enforce most rules automatically, understanding the rationale is still valuable. The core tenets: code is read far more often than it is written.

RuleStandardRationale
Indentation4 spaces per levelTabs cause inconsistent rendering
Line length79 characters (docstrings: 72)Readability in side-by-side diffs and terminals
Blank lines2 around top-level, 1 inside classVisual grouping of logical sections
ImportsStdlib, third-party, local; one per lineClarity and dependency tracking
WhitespaceSpaces around operators, not inside parensConsistency aids scanning
Trailing commasOptional but encouraged in multi-line collectionsClean diffs when adding/removing items
pep8_example.py
Python
1# Good — follows PEP 8
2import os
3import sys
4from typing import Any, Final
5
6import requests
7from rich.console import Console
8
9from mypackage.utils import load_config
10
11MAX_RETRIES: Final[int] = 3
12
13
14def compute_score(data: list[float]) -> float:
15 """Calculate the weighted average score."""
16 if not data:
17 return 0.0
18 total = sum(data)
19 return total / len(data)
20
21
22# Bad — violates PEP 8
23import os, sys
24from mypackage.utils import load_config, compute_score
25import requests
26
27def compute_score(data):return sum(data)/len(data)

info

Do not manually enforce PEP 8. Use ruff format or black to auto-format your code. Add ruff check as a pre-commit hook and in CI. The --line-length option is the most common configuration change teams make.
Naming Conventions

PEP 8 defines clear naming conventions for Python identifiers. Names convey intent — a well-named variable eliminates the need for a comment. The standard is snake_case for most things, PascalCase for classes, and UPPER_CASE for constants.

CategoryConventionExample
Variablessnake_caseuser_name, item_count
Functionssnake_caseget_user(), validate_input()
ClassesPascalCaseUserAccount, HttpConnection
Methodssnake_casefetch_data(), process_item()
Private (weak)_single_leading_underscore_internal, _helper()
Private (strong)__double_leading_underscore__private_attr (name mangled)
ConstantsUPPER_SNAKE_CASEMAX_SIZE, DEFAULT_TIMEOUT
Type variablesPascalCase, shortT, K, V, ItemT
Modulessnake_case, shortutils, db, http_client
Packagesshort, no underscores preferredmypackage, myapp
Dunder methods__dunder____init__, __str__, __eq__
ExceptionsPascalCase, suffix ErrorValidationError, NotFoundError

best practice

Never use a single leading underscore to mean "protected" — it is purely a naming convention with no language-level enforcement. Double leading underscores trigger name mangling and should only be used to avoid name collisions in subclass hierarchies, not for access control.
Project Structure

Two main layouts dominate Python projects. The flat layout places modules at the repository root. The src layout nests them under a src/ directory. The src layout is preferred for packages intended for distribution — it forces tests to import via the installed package, preventing subtle bugs where tests import directly from source.

project_structure.py
Python
1# Flat layout (good for small projects, scripts)
2# myproject/
3# mypackage/
4# __init__.py
5# core.py
6# tests/
7# test_core.py
8# pyproject.toml
9# README.md
10#
11# Tests can accidentally import from ./mypackage directly.
12
13# src layout (recommended for distributable packages)
14# myproject/
15# src/
16# mypackage/
17# __init__.py
18# core.py
19# tests/
20# test_core.py
21# pyproject.toml
22# README.md
23#
24# Tests import the installed package, catching missing
25# files and incorrect dependency declarations early.
26
27# pyproject.toml (src layout)
28# [project]
29# name = "mypackage"
30# version = "0.1.0"
31# ...
32#
33# [tool.setuptools.packages.find]
34# where = ["src"]

The __init__.py file marks a directory as a Python package. Its contents control the package's public API via __all__. Keep it minimal — import names from submodules and define __all__ explicitly.

__init__.py
Python
1# src/mypackage/__init__.py
2"""MyPackage — utilities for data processing."""
3
4from mypackage.core import process_data, load_file
5from mypackage.validation import validate_schema
6
7__all__ = [
8 "process_data",
9 "load_file",
10 "validate_schema",
11]
12
13# Prefer explicit re-exports over star imports
14# Users then write:
15# from mypackage import process_data
16# Not:
17# from mypackage import * (relies on __all__)
18
19# Guard for script execution
20if __name__ == "__main__":
21 # python -m mypackage
22 import sys
23 print(f"MyPackage v0.1.0 — Python {sys.version}")

info

Use python -m mypackage to run a package as a script. The if __name__ == "__main__" guard ensures code only runs when the module is executed directly, not when imported. Every module that can be run standalone should include this guard.
Pythonic Idioms

"Pythonic" code follows the language's idioms rather than patterns borrowed from C, Java, or other languages. These idioms leverage Python's dynamic dispatch, first-class functions, and protocol-based type system.

pythonic_idioms.py
Python
1# ——— EAFP vs LBYL ———
2# EAFP: "Easier to Ask for Forgiveness than Permission"
3# Ask for forgiveness: try the operation, catch if it fails.
4# LBYL: "Look Before You Leap"
5# Check everything before attempting the operation.
6
7# Pythonic — EAFP
8def get_value(data: dict[str, int], key: str) -> int | None:
9 try:
10 return data[key]
11 except KeyError:
12 return None
13
14# Less Pythonic — LBYL
15def get_value_lbyl(data: dict[str, int], key: str) -> int | None:
16 if key in data:
17 return data[key]
18 return None
19
20# EAFP shines with concurrent access — the check and operation
21# are atomic and race-condition free.
22
23# ——— Duck Typing ———
24# "If it walks like a duck and quacks like a duck, it's a duck."
25def process_items(items):
26 """Works with any iterable — list, set, generator, etc."""
27 for item in items: # the for loop calls iter()
28 yield transform(item)
29
30# Python accepts any object that implements __iter__
31# No need for abstract base classes or interface checks.
32
33# ——— Context Managers for Resource Cleanup ———
34# Always use 'with' for files, locks, connections, etc.
35def read_config(path: str) -> dict[str, Any]:
36 with open(path) as f: # automatically closes on exit
37 import json
38 return json.load(f)
39
40# Custom context manager
41@contextmanager
42def managed_resource(name: str):
43 print(f"acquiring {name}")
44 try:
45 yield name
46 finally:
47 print(f"releasing {name}")
48
49with managed_resource("database") as res:
50 print(f"using {res}")
51
52# ——— Constants Naming ———
53# Constants are upper-case with underscores.
54# Use Final[] for type-checked constants.
55from typing import Final
56
57DEFAULT_TIMEOUT: Final[float] = 30.0
58API_BASE_URL: Final[str] = "https://api.example.com/v2"
59SUPPORTED_FORMATS: Final[frozenset[str]] = frozenset({"csv", "json", "xml"})

info

Duck typing works with protocols. For static type checking, use Protocol from typing to define structural subtyping. This gives you the flexibility of duck typing with the safety of static analysis.
purity_srp.py
Python
1# ——— Function Purity ———
2# Pure functions: same input → same output, no side effects.
3# They are easier to test, reason about, and compose.
4
5# Pure — no side effects
6def calculate_discount(price: float, rate: float) -> float:
7 return price * (1 - rate)
8
9# Impure — depends on external state
10DISCOUNT_RATE = 0.1
11def apply_discount(price: float) -> float:
12 return price * (1 - DISCOUNT_RATE) # hidden dependency
13
14# Impure — modifies input
15def add_item(cart: list[str], item: str) -> None:
16 cart.append(item) # mutates the argument
17
18# Pure — returns new value
19def add_item_pure(cart: tuple[str, ...], item: str) -> tuple[str, ...]:
20 return (*cart, item)
21
22# ——— Single Responsibility Per Function ———
23# One function = one transformation or decision.
24def fetch_user(user_id: int) -> dict[str, Any]:
25 """Fetch user data from the API."""
26 raise NotImplementedError
27
28def format_user_name(user: dict[str, Any]) -> str:
29 """Extract and format the user's full name."""
30 return f"{user['first_name']} {user['last_name']}"
31
32def send_welcome_email(user: dict[str, Any]) -> None:
33 """Send welcome email after registration."""
34 raise NotImplementedError
35
36# Not this:
37def process_user(user_id: int) -> None:
38 user = fetch_user(user_id)
39 name = format_user_name(user)
40 send_welcome_email(user) # three responsibilities, one function
Docstrings & Type Hints

PEP 257 defines docstring conventions. Public modules, classes, functions, and methods should have docstrings. Three major docstring formats are in common use: Google, NumPy, and Sphinx/reStructuredText. Of these, Google style is the most readable and widely adopted in modern projects.

docstrings.py
Python
1# ——— Google-style docstrings (recommended) ———
2def calculate_metrics(
3 values: list[float],
4 *,
5 precision: int = 2,
6) -> dict[str, float]:
7 """Calculate summary statistics for a list of numbers.
8
9 Args:
10 values: Sequence of numeric values to analyze.
11 precision: Number of decimal places for rounding.
12
13 Returns:
14 Dictionary with keys 'mean', 'min', 'max', 'std'.
15
16 Raises:
17 ValueError: If values is empty.
18
19 Example:
20 >>> calculate_metrics([1.0, 2.0, 3.0], precision=1)
21 {'mean': 2.0, 'min': 1.0, 'max': 3.0, 'std': 0.8}
22 """
23 if not values:
24 raise ValueError("values must not be empty")
25 import statistics
26 return {
27 "mean": round(statistics.mean(values), precision),
28 "min": round(min(values), precision),
29 "max": round(max(values), precision),
30 "std": round(statistics.stdev(values), precision),
31 }
32
33# ——— NumPy-style docstrings ———
34def metrics_numpy(
35 values: list[float],
36 precision: int = 2,
37) -> dict[str, float]:
38 """Calculate summary statistics.
39
40 Parameters
41 ----------
42 values : list[float]
43 Sequence of numeric values to analyze.
44 precision : int, optional
45 Decimal places for rounding (default is 2).
46
47 Returns
48 -------
49 dict[str, float]
50 Mean, min, max, and standard deviation.
51
52 Raises
53 ------
54 ValueError
55 If values is empty.
56 """
57 ...
58
59# ——— Type Hints in Public APIs ———
60# Always annotate public function signatures.
61# Use generic types generously.
62from typing import Sequence, TypeVar, overload
63
64T = TypeVar("T")
65
66def first(items: Sequence[T]) -> T | None:
67 """Return the first item or None if empty."""
68 return items[0] if items else None
69
70# Annotate __init__.py exports
71# src/mypackage/__init__.py
72# from mypackage.core import process_data # type: ignore

best practice

Write docstrings as explanations of why and what, not how — the code itself documents how. Use type hints for parameter and return types. Add a docstring only when the function's purpose or contract is not immediately obvious from its name and signature.
Design Patterns & Principles

Python's dynamic nature makes some classical GoF patterns obsolete while making others more powerful. The patterns that survive in idiomatic Python leverage first-class functions, descriptors, and protocols.

patterns.py
Python
1# ——— Composition Over Inheritance ———
2# Favor composing small, focused objects over deep class trees.
3
4# Prefer composition:
5class Logger:
6 def log(self, msg: str) -> None: ...
7
8class Database:
9 def __init__(self, logger: Logger) -> None:
10 self.logger = logger # composed, not inherited
11
12 def save(self, data: dict) -> None:
13 self.logger.log("saving...")
14 ...
15
16# Over inheritance:
17class LoggedDatabase(Logger): # is-a Logger? semantically wrong
18 def save(self, data: dict) -> None:
19 self.log("saving...")
20 ...
21
22# ——— Logging vs Print ———
23# Use the logging module for production code.
24# Use print only for quick debugging (remove before commit).
25
26import logging
27
28logger = logging.getLogger(__name__)
29
30def process(data: str) -> None:
31 logger.debug("starting process with %s chars", len(data))
32 try:
33 result = expensive_transform(data)
34 logger.info("process completed: %s", result)
35 except ValueError as exc:
36 logger.error("process failed: %s", exc)
37 raise
38
39# Configuration at application entry point:
40# logging.basicConfig(
41# level=logging.INFO,
42# format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
43# )
44
45# ——— Strategy via Callables ———
46# Don't create Strategy classes — just pass functions.
47
48def discount_standard(price: float) -> float:
49 return price * 0.9
50
51def discount_premium(price: float) -> float:
52 return price * 0.8
53
54def calculate_price(
55 price: float,
56 discount_strategy: Callable[[float], float],
57) -> float:
58 return discount_strategy(price)
59
60# Usage: calculate_price(100.0, discount_premium)
61
62# ——— Dependency Injection ———
63# Pass dependencies explicitly rather than importing them.
64
65# Good — inject the dependency
66def get_data(api_client: APIClient, url: str) -> dict:
67 return api_client.fetch(url)
68
69# Bad — hard-coded dependency
70def get_data_bad(url: str) -> dict:
71 from mypackage.client import APIClient
72 client = APIClient() # hard to test, hard to swap
73 return client.fetch(url)

info

The logging module is thread-safe, configurable at runtime, and supports multiple outputs (file, stderr, network). print buffers to stdout differently and provides none of these guarantees. Always prefer logging in production code.
Tooling & Version Management

Modern Python projects rely on a stack of tools for formatting, linting, type-checking, dependency management, and version isolation. Choosing the right tools and configuring them consistently across the team is essential.

pyproject.toml
TOML
1# pyproject.toml — consolidated tool configuration
2
3[tool.ruff]
4line-length = 88
5target-version = "py312"
6
7[tool.ruff.lint]
8select = ["E", "F", "I", "N", "W", "UP", "B", "SIM", "ARG"]
9ignore = ["E501"] # line length handled by formatter
10
11[tool.ruff.format]
12quote-style = "double"
13
14[tool.mypy]
15python_version = "3.12"
16strict = true
17ignore_missing_imports = true
18disallow_untyped_defs = true
19
20[tool.pytest.ini_options]
21testpaths = ["tests"]
22addopts = "-v --strict-markers --tb=short"
23filterwarnings = ["error"]
24
25[tool.coverage.run]
26source = ["src"]
27omit = ["*/tests/*"]
28
29[tool.coverage.report]
30exclude_lines = [
31 "pragma: no cover",
32 "if __name__ == .__main__.:",
33 "raise NotImplementedError",
34]
35fail_under = 80

For version management, use pyenv to manage multiple Python interpreters and uv or pipenv for per-project virtual environments. Pin dependencies with pip freeze or lock files (uv lock, poetry lock) to ensure reproducible builds.

tooling.py
Python
1# ——— Dependency Pinning ———
2# requirements.in (human-readable, direct dependencies only)
3# requests>=2.28
4# click>=8.0
5# pydantic>=2.0
6
7# requirements.txt (pinned, generated by pip-compile or uv)
8# # This file is autogenerated by pip-compile
9# requests==2.31.0
10# --hash=sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f2e7d6c134f
11# certifi==2024.2.2
12# --hash=sha256:...
13#
14# Generate with:
15# pip install pip-tools
16# pip-compile requirements.in > requirements.txt
17# pip-sync requirements.txt
18
19# ——— Version Management ———
20# .python-version (pyenv reads this automatically)
21# 3.12.3
22#
23# Install: pyenv install 3.12.3
24# Set local: pyenv local 3.12.3
25# Use with uv: uv python pin 3.12.3
26
27# ——— Pre-commit hooks ———
28# .pre-commit-config.yaml
29# repos:
30# - repo: https://github.com/astral-sh/ruff-pre-commit
31# rev: v0.4.0
32# hooks:
33# - id: ruff
34# - id: ruff-format
35# - repo: https://github.com/pre-commit/mirrors-mypy
36# rev: v1.9.0
37# hooks:
38# - id: mypy

best practice

Always use >= for direct dependency bounds (not ==), then pin with a lock file. This avoids conflicts when multiple packages depend on the same library. Commit lock files to version control.
Code Review Checklist

A structured review checklist ensures consistency across the team. Run through these items for every Python pull request. Automate as many as possible with CI — human review time is best spent on design and logic, not formatting.

CheckAutomated?Details
Formattingruff formatPEP 8 conformance, consistent style
Lintingruff checkCommon bugs, unused imports, complexity
Typesmypy --strictPublic APIs annotated, no Any leaks
TestspytestNew code has tests, existing tests pass
CoveragecoverageAbove fail threshold, no uncovered critical paths
ImportsruffSorted, no unused, no wildcard imports
Securitybandit / reviewNo eval/exec, safe subprocess, input validated
Dependenciespip-audit / reviewNo known vulnerabilities, pins are accurate
LoggingReviewUses logging not print in production paths
Docstringsruff / reviewPublic APIs documented, style consistent
NamingReviewSnake_case, PascalCase, Upper_CASE as appropriate
Error handlingReviewEAFP, no bare except, specific exception types
ci_checklist.sh
Bash
1# Run the full CI pipeline locally before pushing
2ruff format --check src/ tests/
3ruff check src/ tests/
4mypy src/
5pytest tests/ --cov=src/ --cov-report=term-missing
6pip-audit -r requirements.txt
$Blueprint — Engineering Documentation·Section ID: PYTHON-BP·Revision: 1.0