Python General Best Practices (2026)
Language-level Python best practices covering error handling, context managers, data modeling choices (dataclasses vs Pydantic vs dicts), logging, pathlib, and structural pattern matching for maintainable production code.
Introduction / Overview
Beyond style and typing, writing idiomatic, maintainable Python in 2026 requires deliberate choices about how errors are handled, how data is modeled, how resources are managed, and how observability is built in from the start. These decisions matter more than micro-syntax and are frequently where code reviews find the most impactful issues.
This document is language-level and framework-agnostic; it applies equally to a FastAPI service, a Django project, a CLI tool, or a data pipeline. Framework-specific guidance (dependency injection, ORMs, request/response models) lives in the respective framework documents.
When to use this guidance: Writing or reviewing any non-trivial Python module, service, or library, particularly around error boundaries, data modeling, and logging setup.
Core Concepts
- Fail loudly, fail early: Errors should never pass silently unless explicitly and narrowly suppressed. Catching broad exceptions and continuing execution hides bugs until they surface far from their root cause.
- Explicit resource lifecycles: Any resource with an acquire/release pair (files, sockets, locks, DB transactions) belongs behind a context manager, not manual try/finally scattered through business logic.
- Right-sized data structures: A fixed set of named fields is a
dataclassor a Pydantic model, never a baredict. The choice betweendataclass, Pydantic, andTypedDictdepends on whether you need runtime validation, serialization, or just structural typing. - Structured, leveled logging: Logging is an operational contract, not a debugging afterthought. Use the standard
loggingmodule with structured fields, notprint(), and configure levels/handlers once at the application entry point. pathlibas the default filesystem API:pathlib.Pathprovides an object-oriented, cross-platform, composable API that eliminates entire classes of string-concatenation path bugs.
Best Practices
1. Catch Specific Exceptions, Never Bare except:
Rationale: Bare except: (or except Exception: without re-raising) swallows programming errors (KeyboardInterrupt, TypeError, AttributeError from a typo) alongside expected failures, making bugs silent and debugging far harder.
import logging
logger = logging.getLogger(__name__)
def load_config(path: Path) -> dict[str, str]:
try:
return json.loads(path.read_text())
except FileNotFoundError:
logger.warning("Config file %s not found, using defaults", path)
return {}
except json.JSONDecodeError as exc:
logger.error("Config file %s is not valid JSON: %s", path, exc)
raise
Why this works well in production: The failure mode for a missing file (recoverable, use defaults) is clearly distinct from malformed JSON (a bug that must not be silently ignored). Reviewers can see the intended behavior for each error class.
2. Use Context Managers for Every Acquire/Release Pattern
Rationale: contextlib makes resource cleanup deterministic even under exceptions, and contextlib.contextmanager lets you build custom ones without writing a full class.
from contextlib import contextmanager
from collections.abc import Iterator
import sqlite3
@contextmanager
def transaction(connection: sqlite3.Connection) -> Iterator[sqlite3.Cursor]:
cursor = connection.cursor()
try:
yield cursor
connection.commit()
except Exception:
connection.rollback()
raise
finally:
cursor.close()
with transaction(connection) as cursor:
cursor.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", (100, 1))
cursor.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", (100, 2))
Production tip: Use contextlib.ExitStack when the number of context managers to enter is dynamic (e.g., opening a variable list of files).
3. Choose the Right Data Modeling Tool for the Job
| Tool | Runtime validation | Serialization | Mutability | Best for |
|---|---|---|---|---|
dict | None | Manual | Mutable | Ad-hoc, short-lived, dynamic-shape data only |
TypedDict | None (static only) | Native (it's a dict) | Mutable | Interop with JSON-like structures that must remain plain dicts at runtime |
dataclass | None (unless added) | Manual (asdict) | Configurable (frozen=True) | Internal domain objects, value objects, no external input |
Pydantic BaseModel | Full, at construction | Built-in (modeldump, modeldump_json) | Configurable | External input (API payloads, config files, env vars) |
from dataclasses import dataclass
from pydantic import BaseModel, Field
# Internal value object: trusted, no runtime validation needed
@dataclass(frozen=True, slots=True)
class Money:
amount_cents: int
currency: str
# External input: must be validated at the boundary
class CreateOrderRequest(BaseModel):
customer_email: str = Field(max_length=255)
items: list[str] = Field(min_length=1)
Rule of thumb: If the data crosses a trust boundary (network, file, user input), validate it with Pydantic. If it is constructed internally from already-validated data, a dataclass is lighter-weight and has zero validation overhead.
4. Configure logging Once, at the Entry Point
Rationale: Library code should never call logging.basicConfig() or attach handlers; only the application's entry point owns logging configuration. Libraries should simply call logging.getLogger(__name__) and emit records.
import logging
import sys
def configure_logging(level: str = "INFO") -> None:
logging.basicConfig(
level=level,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
stream=sys.stdout,
)
# In library modules:
logger = logging.getLogger(__name__)
logger.debug("Cache miss for key=%s", key)
logger.info("Processed %d records in %.2fs", count, elapsed)
Production tip: For services with log aggregation (ELK, Loki, Datadog), use a structured formatter (python-json-logger or a custom logging.Formatter) so fields like requestid and userid are queryable rather than embedded in free text.
5. Prefer pathlib.Path Over os.path String Manipulation
Rationale: pathlib composes paths with /, normalizes separators across platforms, and exposes filesystem operations as methods, reducing an entire class of string-concatenation bugs.
from pathlib import Path
# Legacy os.path style
import os
config_path = os.path.join(os.path.dirname(__file__), "config", "settings.json")
# Modern pathlib style
config_path = Path(__file__).parent / "config" / "settings.json"
if config_path.exists():
data = config_path.read_text(encoding="utf-8")
Why this matters for security: pathlib.Path.resolve() combined with an explicit isrelativeto() check is the standard idiom for preventing path traversal from user-supplied filenames - always validate resolved paths stay within an allowed root directory before reading or writing.
6. Use Structural Pattern Matching for Genuine Structural Dispatch
Rationale: match/case shines when dispatching on the shape of data (variant types, nested structures, sequences), not as a general replacement for if/elif chains on simple values.
match command:
case {"action": "create", "payload": {"name": str(name)}}:
create_resource(name)
case {"action": "delete", "payload": {"id": int(resource_id)}}:
delete_resource(resource_id)
case {"action": action}:
raise ValueError(f"Unknown action: {action}")
case _:
raise ValueError("Malformed command")
When to prefer if/elif instead: Simple scalar comparisons (if status == "active") are clearer as if/elif or a dict-based dispatch table; reserve match for genuine structural decomposition.
Common Pitfalls & Anti-Patterns
Pitfall: Using Mutable Default Arguments
Problem: def add_item(item, items=[]) reuses the same list object across every call without an explicit argument, silently accumulating state.
Recommended approach:
def add_item(item: str, items: list[str] | None = None) -> list[str]:
items = items if items is not None else []
items.append(item)
return items
Pitfall: Broad except Exception: pass
Problem: Silently discards all failures, including bugs, making production incidents nearly impossible to diagnose.
Recommended approach: Catch the narrowest exception type possible, log at an appropriate level, and re-raise unless the failure is genuinely expected and handled.
Pitfall: Using print() for Application Logging
Problem: print() output cannot be leveled, filtered, or routed to log aggregation systems, and is easy to forget and leave in production code.
Recommended approach: Use logging.getLogger(__name__) everywhere; reserve print() for CLI tools whose explicit purpose is stdout output.
Pitfall: Overusing dict for Structured Domain Data
Problem: dict-typed domain objects have no static field validation, invite typos in string keys (d["usre_id"]), and provide no IDE autocomplete.
Recommended approach: Promote any dict with a fixed, known key set to a dataclass or Pydantic model (see Best Practice #3).
Testing Strategies
- Unit test error-handling branches explicitly: assert that the specific exception type is raised (
pytest.raises(ValueError, match="...")), not just "an exception occurs." - Test context managers for correct cleanup under both success and failure paths - assert that resources are released even when the body raises.
- For data models, test both valid and invalid inputs for Pydantic models (validation errors), and test equality/immutability semantics for frozen dataclasses.
- Capture and assert on log output using
caplog(pytest's built-in fixture) rather than mocking the logging module directly.
def test_load_config_missing_file_uses_defaults(tmp_path, caplog):
missing = tmp_path / "does_not_exist.json"
with caplog.at_level(logging.WARNING):
config = load_config(missing)
assert config == {}
assert "not found" in caplog.text
See Python Testing Guide and Unit Testing Principles for the full pytest ecosystem.
Performance, Security & Scalability Considerations
- Exception handling cost: Exceptions in CPython are cheap to raise and catch relative to most I/O costs; do not avoid exceptions for "performance" at the cost of readability. Reserve
EAFP(Easier to Ask Forgiveness than Permission) style for the common case, not exotic micro-optimization. - Logging overhead: Use lazy
%sformatting (logger.info("value=%s", value)) rather than f-strings in log calls (logger.info(f"value={value}")) so the string is only formatted when the log level is enabled. - Data validation cost: Pydantic v2's Rust core makes validation fast, but validating the same external payload multiple times across layers is wasted work - validate once at the trust boundary and pass validated objects inward.
- Path traversal: Always resolve and validate user-supplied paths against an allow-listed root directory before file access; this is a common OWASP-class vulnerability in upload/download endpoints.
- Least-privilege process execution: Code that shells out via
subprocessor invokes external tooling should never assume or require root; see Autonomous, Least-Privilege, Portable Execution Environments for the standard governing non-root, non-interactive tool execution more broadly.
Edge Cases & Advanced Usage
- **Exception groups (
except*)**: Since Python 3.11,except*handles multiple concurrent exceptions raised together (e.g., fromasyncio.TaskGroup); use it when a single operation can fail in multiple independent ways simultaneously. - Chained exceptions: Use
raise NewError(...) from original_excto preserve the original traceback context when translating low-level exceptions into domain-specific ones. - Frozen dataclasses with
slots=True: Combinefrozen=Trueandslots=Truefor immutable value objects with minimal memory overhead and hashability out of the box. - Pydantic
modelvalidatorfor cross-field validation: Use when a single field's type annotation cannot express the constraint (e.g.,enddate > start_date). - Match statement with guards:
case Point(x=x, y=y) if x == y:combines structural matching with a boolean condition for more expressive dispatch.
When Not to Use / Alternatives
| Dimension | Pydantic Models | Dataclasses | Plain Dicts | Winner |
|---|---|---|---|---|
| Runtime validation of external input | Excellent | None | None | Pydantic |
| Construction/attribute access speed | Good (Rust core) | Excellent | Excellent | Dataclasses/dict |
| Serialization to/from JSON | Built-in | Manual | Native (if primitives) | Pydantic |
| Simplicity for trivial internal data | Overkill | Good | Acceptable for 2-3 keys | Dataclasses |
Use plain dicts only for genuinely dynamic, short-lived, small-scope data (e.g., a local aggregation accumulator) - promote anything passed between functions or modules to a typed structure.
Related Documentation
- Python Language Overview & Ecosystem
- Python Code Style Guide
- Python Testing Guide
- Python Project Structure
- Python Concurrency
- FastAPI Best Practices
- Django Best Practices
- Clean Code Principles
- Unit Testing Principles
Glossary
- EAFP: "Easier to Ask Forgiveness than Permission" - Python's preferred idiom of attempting an operation and catching exceptions rather than pre-checking conditions.
- Context manager: An object implementing
__enter__/__exit__(or a generator wrapped in@contextmanager) that guarantees setup/teardown around a block of code. - Trust boundary: The point at which data enters a system from an external, untrusted source (network request, file upload, environment variable) and must be validated.
- Exception group: A Python 3.11+ construct (
ExceptionGroup,except*) for handling multiple exceptions raised concurrently. - Structural pattern matching: The
match/casesyntax (PEP 634) for dispatching based on the shape of data rather than simple equality.