Python — Best Practices & Patterns
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.
| 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 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.
| Rule | Standard | Rationale |
|---|---|---|
| Indentation | 4 spaces per level | Tabs cause inconsistent rendering |
| Line length | 79 characters (docstrings: 72) | Readability in side-by-side diffs and terminals |
| Blank lines | 2 around top-level, 1 inside class | Visual grouping of logical sections |
| Imports | Stdlib, third-party, local; one per line | Clarity and dependency tracking |
| Whitespace | Spaces around operators, not inside parens | Consistency aids scanning |
| Trailing commas | Optional but encouraged in multi-line collections | Clean diffs when adding/removing items |
| 1 | # Good — follows PEP 8 |
| 2 | import os |
| 3 | import sys |
| 4 | from typing import Any, Final |
| 5 | |
| 6 | import requests |
| 7 | from rich.console import Console |
| 8 | |
| 9 | from mypackage.utils import load_config |
| 10 | |
| 11 | MAX_RETRIES: Final[int] = 3 |
| 12 | |
| 13 | |
| 14 | def 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 |
| 23 | import os, sys |
| 24 | from mypackage.utils import load_config, compute_score |
| 25 | import requests |
| 26 | |
| 27 | def compute_score(data):return sum(data)/len(data) |
info
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.
| Category | Convention | Example |
|---|---|---|
| Variables | snake_case | user_name, item_count |
| Functions | snake_case | get_user(), validate_input() |
| Classes | PascalCase | UserAccount, HttpConnection |
| Methods | snake_case | fetch_data(), process_item() |
| Private (weak) | _single_leading_underscore | _internal, _helper() |
| Private (strong) | __double_leading_underscore | __private_attr (name mangled) |
| Constants | UPPER_SNAKE_CASE | MAX_SIZE, DEFAULT_TIMEOUT |
| Type variables | PascalCase, short | T, K, V, ItemT |
| Modules | snake_case, short | utils, db, http_client |
| Packages | short, no underscores preferred | mypackage, myapp |
| Dunder methods | __dunder__ | __init__, __str__, __eq__ |
| Exceptions | PascalCase, suffix Error | ValidationError, NotFoundError |
best practice
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.
| 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.
| 1 | # src/mypackage/__init__.py |
| 2 | """MyPackage — utilities for data processing.""" |
| 3 | |
| 4 | from mypackage.core import process_data, load_file |
| 5 | from 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 |
| 20 | if __name__ == "__main__": |
| 21 | # python -m mypackage |
| 22 | import sys |
| 23 | print(f"MyPackage v0.1.0 — Python {sys.version}") |
info
"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.
| 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 |
| 8 | def 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 |
| 15 | def 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." |
| 25 | def 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. |
| 35 | def 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 |
| 42 | def managed_resource(name: str): |
| 43 | print(f"acquiring {name}") |
| 44 | try: |
| 45 | yield name |
| 46 | finally: |
| 47 | print(f"releasing {name}") |
| 48 | |
| 49 | with 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. |
| 55 | from typing import Final |
| 56 | |
| 57 | DEFAULT_TIMEOUT: Final[float] = 30.0 |
| 58 | API_BASE_URL: Final[str] = "https://api.example.com/v2" |
| 59 | SUPPORTED_FORMATS: Final[frozenset[str]] = frozenset({"csv", "json", "xml"}) |
info
| 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 |
| 6 | def calculate_discount(price: float, rate: float) -> float: |
| 7 | return price * (1 - rate) |
| 8 | |
| 9 | # Impure — depends on external state |
| 10 | DISCOUNT_RATE = 0.1 |
| 11 | def apply_discount(price: float) -> float: |
| 12 | return price * (1 - DISCOUNT_RATE) # hidden dependency |
| 13 | |
| 14 | # Impure — modifies input |
| 15 | def add_item(cart: list[str], item: str) -> None: |
| 16 | cart.append(item) # mutates the argument |
| 17 | |
| 18 | # Pure — returns new value |
| 19 | def 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. |
| 24 | def fetch_user(user_id: int) -> dict[str, Any]: |
| 25 | """Fetch user data from the API.""" |
| 26 | raise NotImplementedError |
| 27 | |
| 28 | def 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 | |
| 32 | def send_welcome_email(user: dict[str, Any]) -> None: |
| 33 | """Send welcome email after registration.""" |
| 34 | raise NotImplementedError |
| 35 | |
| 36 | # Not this: |
| 37 | def 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 |
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.
| 1 | # ——— Google-style docstrings (recommended) ——— |
| 2 | def 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 ——— |
| 34 | def 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. |
| 62 | from typing import Sequence, TypeVar, overload |
| 63 | |
| 64 | T = TypeVar("T") |
| 65 | |
| 66 | def 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
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.
| 1 | # ——— Composition Over Inheritance ——— |
| 2 | # Favor composing small, focused objects over deep class trees. |
| 3 | |
| 4 | # Prefer composition: |
| 5 | class Logger: |
| 6 | def log(self, msg: str) -> None: ... |
| 7 | |
| 8 | class 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: |
| 17 | class 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 | |
| 26 | import logging |
| 27 | |
| 28 | logger = logging.getLogger(__name__) |
| 29 | |
| 30 | def 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 | |
| 48 | def discount_standard(price: float) -> float: |
| 49 | return price * 0.9 |
| 50 | |
| 51 | def discount_premium(price: float) -> float: |
| 52 | return price * 0.8 |
| 53 | |
| 54 | def 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 |
| 66 | def get_data(api_client: APIClient, url: str) -> dict: |
| 67 | return api_client.fetch(url) |
| 68 | |
| 69 | # Bad — hard-coded dependency |
| 70 | def 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
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.
| 1 | # pyproject.toml — consolidated tool configuration |
| 2 | |
| 3 | [tool.ruff] |
| 4 | line-length = 88 |
| 5 | target-version = "py312" |
| 6 | |
| 7 | [tool.ruff.lint] |
| 8 | select = ["E", "F", "I", "N", "W", "UP", "B", "SIM", "ARG"] |
| 9 | ignore = ["E501"] # line length handled by formatter |
| 10 | |
| 11 | [tool.ruff.format] |
| 12 | quote-style = "double" |
| 13 | |
| 14 | [tool.mypy] |
| 15 | python_version = "3.12" |
| 16 | strict = true |
| 17 | ignore_missing_imports = true |
| 18 | disallow_untyped_defs = true |
| 19 | |
| 20 | [tool.pytest.ini_options] |
| 21 | testpaths = ["tests"] |
| 22 | addopts = "-v --strict-markers --tb=short" |
| 23 | filterwarnings = ["error"] |
| 24 | |
| 25 | [tool.coverage.run] |
| 26 | source = ["src"] |
| 27 | omit = ["*/tests/*"] |
| 28 | |
| 29 | [tool.coverage.report] |
| 30 | exclude_lines = [ |
| 31 | "pragma: no cover", |
| 32 | "if __name__ == .__main__.:", |
| 33 | "raise NotImplementedError", |
| 34 | ] |
| 35 | fail_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.
| 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
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.
| Check | Automated? | Details |
|---|---|---|
| Formatting | ruff format | PEP 8 conformance, consistent style |
| Linting | ruff check | Common bugs, unused imports, complexity |
| Types | mypy --strict | Public APIs annotated, no Any leaks |
| Tests | pytest | New code has tests, existing tests pass |
| Coverage | coverage | Above fail threshold, no uncovered critical paths |
| Imports | ruff | Sorted, no unused, no wildcard imports |
| Security | bandit / review | No eval/exec, safe subprocess, input validated |
| Dependencies | pip-audit / review | No known vulnerabilities, pins are accurate |
| Logging | Review | Uses logging not print in production paths |
| Docstrings | ruff / review | Public APIs documented, style consistent |
| Naming | Review | Snake_case, PascalCase, Upper_CASE as appropriate |
| Error handling | Review | EAFP, no bare except, specific exception types |
| 1 | # Run the full CI pipeline locally before pushing |
| 2 | ruff format --check src/ tests/ |
| 3 | ruff check src/ tests/ |
| 4 | mypy src/ |
| 5 | pytest tests/ --cov=src/ --cov-report=term-missing |
| 6 | pip-audit -r requirements.txt |