Python Best Practices

A capstone synthesis of the habits that separate code that merely works from code a team can trust, maintain, and safely change — readability, explicit interfaces, and disciplined engineering process together.

This is a capstone topic: it draws together practices taught in depth elsewhere on this site — type hints, context managers, testing, logging — into one coherent picture of what "writing good Python" means day to day. None of these are exotic language features; they're disciplined applications of ordinary tools, guided by the values the Zen of Python (import this) states directly: readability counts, explicit is better than implicit, and simple is better than complex.

What "Best Practices" Means Here

Best practices aren't a checklist to apply mechanically to every line of code — they're defaults you deviate from deliberately, with a reason, rather than defaults you never learned in the first place. Each practice below trades a small amount of upfront effort (an explicit type hint, a test, a docstring) for a larger amount of downstream clarity, safety, or maintainability — and each links to a dedicated topic on this site for full depth.

Write Readable Code

Code is read far more often than written. Meaningful names beat clever abbreviations, small functions with one clear responsibility beat sprawling ones, and consistent style (see the PEP 8 & Code Style and Formatting & Linting topics) removes an entire category of distraction so a reader's attention goes to logic, not layout.

# harder to read: unclear names, one dense expression
def f(d):
    return {k: v for k, v in d.items() if v > 0 and k[0] != "_"}

# clearer: names explain intent, logic is easy to scan
def public_positive_entries(values: dict[str, int]) -> dict[str, int]:
    return {
        key: value
        for key, value in values.items()
        if value > 0 and not key.startswith("_")
    }

Explicit Interfaces

A function or class's public interface — its parameters, return type, and what it raises — should be discoverable from its signature and docstring, without reading the implementation. Avoid **kwargs-only signatures that hide what's actually accepted, avoid mutating arguments the caller didn't expect you to mutate, and keep a module's public API (what it intends callers to use) distinct from its internals (prefixed with _, or controlled via __all__).

# implicit: what does this actually accept? only readable by
# reading the whole function body
def configure(**kwargs):
    ...

# explicit: the signature is the documentation
def configure(*, timeout: float = 30.0, retries: int = 3, verbose: bool = False) -> None:
    ...

Choose Appropriate Abstractions

An abstraction (a class, a decorator, a plugin system) earns its complexity only when it removes more complexity than it adds. Building a general, configurable framework for a need that's actually simple and unlikely to grow is over-engineering — it adds indirection a reader must trace through for no real benefit. The opposite failure, under-abstracting duplicated logic across many call sites, makes every future change a multi-place edit. The practical rule of thumb: solve the problem you actually have, and refactor toward an abstraction once a real second or third use case shows what it should look like — not speculatively.

Prefer Immutable Data Where Useful

Immutable objects (tuples, frozensets, @dataclass(frozen=True) instances, str) can't change state after creation, which eliminates an entire class of bugs: aliasing surprises (mutating an object through one reference and being surprised another reference sees the change) and unsafe sharing across threads. Reach for immutability by default for data that represents a fixed value or a completed fact (a coordinate, a configuration snapshot, a completed order); reach for mutability deliberately when something genuinely needs to change in place (an accumulator, a cache).

from dataclasses import dataclass

@dataclass(frozen=True)
class Point:
    x: float
    y: float

p = Point(1.0, 2.0)
# p.x = 5.0   # raises FrozenInstanceError -- can't accidentally mutate it

Use Context Managers for Resources

Anything with an acquire/release lifecycle — files, network connections, locks, database transactions — should be managed with with, so cleanup runs even when an exception is raised partway through. See the related Context Managers and Common Pitfalls (Resource Leaks) topics for the underlying protocol and the bug this practice prevents.

with open("report.csv") as f, lock:
    process(f)
# both the file and the lock are released here, even on exception

Use Type Hints for Public/Complex Interfaces

Type hints document a function's contract in a form both humans and tools can check (see the Type Hints and Type Checking & Static Analysis topics). They're most valuable on public functions/classes and anywhere the types aren't obvious from context; a private one-line helper with an obvious signature gains less. Gradual typing means this can be adopted incrementally rather than all at once.

def parse_duration(text: str) -> float:
    """Parse a duration like '90s' or '2m' into seconds."""
    ...

Write Tests

Tests (via the standard library's unittest or the widely used third-party pytest) turn "I believe this works" into "this is verified to work, and will keep being verified on every future change." A test suite is what makes refactoring safe — without one, every change to working code is a gamble on not having broken something silently.

Use Logging Instead of Print

The standard library's logging module gives structured, leveled (DEBUG/INFO/WARNING/ERROR), and configurable output — a caller of your library can turn logging up or down, route it to a file, or filter by level, none of which is possible with a scattered print() call. Reserve print() for genuine user-facing command-line output; use logging for anything diagnostic.

import logging

logger = logging.getLogger(__name__)

def process_order(order_id: int) -> None:
    logger.info("processing order %s", order_id)
    try:
        ...
    except ValueError:
        logger.exception("failed to process order %s", order_id)
        raise

Dependency Isolation

Each project should manage its own dependencies in an isolated virtual environment (the standard library's venv, or third-party tools like uv/Poetry) rather than installing packages globally — this prevents version conflicts between unrelated projects and keeps a project's dependency list explicit and reproducible (see the related Virtual Environments and Dependency & Supply-Chain Security topics).

Secure Input Handling

Treat anything originating outside your program's trust boundary — user input, a network request body, a file someone else provided — as untrusted until validated. Never build a SQL query by string-formatting untrusted input (use parameterized queries), never eval()/exec() untrusted strings, never pickle.load() untrusted bytes, and validate/sanitize before acting on external data. See the Common Pitfalls topic for concrete examples of the pickle/eval failure modes.

# unsafe: string-built SQL is injectable
cursor.execute(f"SELECT * FROM users WHERE name = '{name}'")

# safe: parameterized query -- the driver handles escaping
cursor.execute("SELECT * FROM users WHERE name = ?", (name,))

Profile Before Optimizing, Avoid Premature Optimization

Intuition about where a program spends its time is frequently wrong. Profiling (the standard library's cProfile, or line-level third-party profilers) measures actual behavior instead of guessing, so optimization effort goes where it actually matters instead of into a part of the program that was never the bottleneck. "Premature optimization" — restructuring code for speed before knowing it's needed — trades away readability and adds complexity for a benefit that may not exist, and that clearer code plus a fast interpreter path (or, when truly needed, a native library or a different algorithm) often makes unnecessary anyway.

Profiling output like this shows slow_parse dominates runtime -- that's where optimization effort belongs, not a guess based on which function 'feels' slow.

$ python -m cProfile -s cumulative myscript.py
   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
       1    0.001    0.001    2.341    2.341  myscript.py:1(<module>)
     100    2.100    0.021    2.100    0.021  myscript.py:12(slow_parse)
   10000    0.240    0.000    0.240    0.000  myscript.py:20(fast_helper)

Document Important Invariants

An invariant is a condition that's always true at a certain point (a list is always sorted, a balance never goes negative, a cache is always consistent with its source). Invariants are often invisible in the code itself — nothing about a data structure's type signature says it's kept sorted — so they need to be written down (docstring, comment, or enforced via an assertion) wherever a future maintainer could break them without realizing. See the related Documentation topic for how to write this down well.

class SortedList:
    """A list that maintains ascending order.

    Invariant: self._items is always sorted after any public method
    returns. Do not append to self._items directly -- use insert().
    """

    def __init__(self) -> None:
        self._items: list[int] = []

    def insert(self, value: int) -> None:
        import bisect
        bisect.insort(self._items, value)

Complete Example

Immutable line items, explicit type hints, a documented failure mode, structured logging instead of print, and a clear single-responsibility function -- several practices from this topic applied together.

"""Order processing service."""

from __future__ import annotations

import logging
from dataclasses import dataclass

logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class LineItem:
    name: str
    unit_price: float
    quantity: int = 1


def calculate_total(items: list[LineItem]) -> float:
    """Return the sum of unit_price * quantity across all items.

    Raises ValueError if any item has a negative price or quantity.
    """
    for item in items:
        if item.unit_price < 0 or item.quantity < 0:
            raise ValueError(f"invalid line item: {item}")
    return sum(item.unit_price * item.quantity for item in items)


def process_order(order_id: int, items: list[LineItem]) -> float:
    logger.info("processing order %s with %d items", order_id, len(items))
    try:
        total = calculate_total(items)
    except ValueError:
        logger.exception("order %s failed validation", order_id)
        raise
    logger.info("order %s total: %.2f", order_id, total)
    return total

Quick Reference

Practice                    Key tool / mechanism
---------------------------  -------------------------------------
Readable code                 meaningful names, PEP 8, small functions
Explicit interfaces            clear signatures, __all__, avoid **kwargs-only
Appropriate abstractions       solve what you have; refactor on real reuse
Immutable data                 tuple, frozenset, @dataclass(frozen=True)
Context managers               with, for any acquire/release resource
Type hints                     mypy/pyright-checked, esp. public APIs
Tests                          unittest / pytest
Logging                        logging module, leveled and configurable
Dependency isolation           venv / uv / Poetry, per project
Secure input handling          parameterized queries, no eval/pickle on untrusted data
Profile before optimizing      cProfile, measure before changing
Document invariants            docstrings/comments for non-obvious guarantees

Key Takeaways

Best practices are defaults to deviate from deliberately, not rules to apply mindlessly — each trades small upfront effort for downstream clarity and safety.

Readability and explicit interfaces compound: consistent style plus clear signatures let a reader trust what a function does without reading its body.

Reach for immutability, context managers, and type hints by default; they remove entire categories of bugs (aliasing, resource leaks, type mismatches) cheaply.

Tests and logging turn "I believe this works" and "I hope I'll notice if it breaks" into verified, observable facts.

Measure before optimizing, and validate/sanitize anything crossing a trust boundary — both are about acting on evidence instead of assumption.

print("processing order", order_id) # scattered print() calls logger.info("processing order %s", order_id) print() output can't be leveled, filtered, or redirected by a caller; logging gives structured, configurable output that a library's consumer controls.

def configure(**kwargs): ... # what does this actually accept?

def configure(*, timeout: float = 30.0, retries: int = 3) -> None: ...

A **kwargs-only signature hides the actual accepted parameters from anyone reading the call site or the signature -- an explicit signature is self-documenting.

Optimizing without measuring trades away clarity for a speed benefit that may not exist or may not matter -- profile first, then optimize only what the data shows is slow.

Why does the text describe best practices as "defaults to deviate from deliberately" rather than fixed rules?

Mechanically applying every practice everywhere (e.g. type-hinting every trivial private helper, or building an abstraction with no second use case) can add more overhead than it saves; the value comes from applying judgment, with a reason when you choose not to follow one.

Why is logging generally preferred over scattered print() calls for diagnostic output in library or application code?

logging supports levels (DEBUG/INFO/WARNING/ERROR), and its output can be configured, filtered, or redirected by whoever is running the code, none of which is possible with plain print() calls baked into the source.

What's the relationship between context managers and resource leaks, as best practices connect the two?

Using with for anything with an acquire/release lifecycle guarantees the release step runs even if an exception is raised inside the block, directly preventing the resource-leak pitfall covered in the Common Pitfalls topic.

Why is "profile before optimizing" listed as a best practice rather than just optimizing wherever code looks slow?

Intuition about where time is spent is frequently wrong; a profiler measures actual behavior, so optimization effort is spent on the real bottleneck instead of a part of the program that was never actually slow.

Why does the text warn against building a general, configurable abstraction before a second real use case exists?

An abstraction built speculatively for hypothetical future needs adds indirection a reader must trace through, without a proven benefit; refactoring toward an abstraction once a real second/third use case exists lets that abstraction be shaped by actual requirements.

Why is a parameterized database query safer than building a SQL string with an f-string containing user input?

String-formatting untrusted input directly into a SQL query allows an attacker to inject SQL syntax (SQL injection); a parameterized query passes the value separately from the query structure, so the driver escapes it correctly and it can never be interpreted as SQL code.

Rewrite this function to use a context manager and a type-hinted, explicit signature: def read(path):\n f = open(path)\n data = f.read()\n f.close()\n return data def read(path: str) -> str: with open(path) as f: return f.read()

The with block guarantees the file is closed even if f.read() raises, and the annotated signature documents the accepted and returned types without needing to read the body.

Write a frozen dataclass Money with amount: float and currency: str fields, illustrating the 'prefer immutable data where useful' practice.

from dataclasses import dataclass @dataclass(frozen=True) class Money: amount: float currency: str frozen=True makes instances immutable after construction (attempting to reassign a field raises FrozenInstanceError), which is appropriate for a value type like a monetary amount that should never be silently mutated once created.

What best practice does this violate, and what's a safer alternative for the name value specifically?

It violates secure input handling by building SQL through string interpolation of untrusted input -- besides being exploitable for SQL injection, it even breaks on ordinary input like O'Brien due to the unescaped quote. The value should be passed as a separate parameter to a parameterized query (e.g. cursor.execute('SELECT * FROM users WHERE name = ?', (name,))), letting the database driver handle escaping safely.

def build_query(table, name):
    return f"SELECT * FROM {table} WHERE name = '{name}'"

query = build_query("users", "O'Brien")