Python Best Practices Actually Used in Production

I've been writing Python long enough to see the same mistakes repeat across different teams and companies. The language has matured significantly over the past decade, and what worked in 2014 often hurts your codebase today. This isn't a curated list from a conference talk. It's the stuff that actually matters when your application is serving real traffic at 3 AM. Most developers skip type hints because they sound like extra work. The reality is that in a medium to large codebase, explicit types prevent an entire category of bugs. When you annotate a function signature with typing.Protocol, you're not just documenting for humans. You're making the code self-validating before it runs. I had a service where a configuration object silently changed shape between deployments because someone renamed a key and nothing caught it at compile time. Type checking with mypy would have flagged that immediately. The practical workaround I settled on was running mypy as a pre-commit hook. The overhead is roughly two to three seconds on a standard project. That's negligible compared to the debugging time saved by catching shape mismatches before they hit production.

Context Managers and Resource Lifecycle

File handles, database connections, network sockets. These things die badly when they aren't cleaned up properly. The standard library's contextlib module gives you tools for exactly this. The @contextmanager decorator turns a simple generator into a proper context manager without the boilerplate of implementing __enter__ and __exit__ yourself. Here's what that looks like in practice: from contextlib import contextmanager
@contextmanager
def managed_transaction(db_conn):
db_conn.begin()
try:
yield db_conn
db_conn.commit()
except Exception:
db_conn.rollback()
raise

This pattern keeps your transaction logic contained. The caller just uses with managed_transaction(conn) and doesn't need to think about commit or rollback paths. I spent an afternoon tracking down a data inconsistency issue caused by an unhandled exception during a batch insert. The connection was open, the transaction was half-committed, and the logging was ambiguous. Switching to this pattern eliminated the entire class of bugs.

Error Handling That Doesn't Stink

Catch specific exceptions. Bare except: clauses are a liability. I once inherited a codebase where a developer caught everything to avoid stack traces in logs. The side effect was that KeyboardInterrupt, SystemExit, and MemoryError were all swallowed silently. Finding that took longer than I care to admit. Use exception groups in Python 3.11+ when you're running multiple concurrent operations and need to report all failures, not just the first one. The ExceptionGroup type and except* syntax are underutilized. They force you to think about whether one failure should abort everything or whether you should collect errors and handle them collectively. The common pitfall here is over-specifying exceptions. If you catch ValueError but the actual failure is a TypeError from a downstream call, your handler does nothing and the error propagates anyway, often without clear context. Wrap generic exceptions with a custom exception class that includes relevant context data. A simple wrapper with a message and the original traceback attached is better than nothing.

Logging Without the Pain

The standard logging module is adequate. Most teams configure it once and forget it, which is why you see production logs full of unstructured debug prints that somehow ended up in the right place. Configure logging early in your application startup, before any imports that might trigger logging. Otherwise you get the default handler behavior and wonder why your messages are going to stderr instead of wherever you intended. Structure your log entries with structlog or at minimum use the logging dictConfig for JSON output. The difference between searching a plain-text log file and querying structured JSON logs in a tool like Elasticsearch is the difference between finding an issue in thirty seconds and spending an afternoon doing it. This isn't opinion. It's operational reality.

Get the Full Details

Grainy Texture Vectors & Illustrations for Free Download | Freepik ...
Grainy Texture Vectors & Illustrations for Free Download | Freepik ...

Async Code That Doesn't Mutate State Unexpectedly

Asyncio has gotten better. The task group API in Python 3.11 is far less error-prone than manually creating and tracking tasks. But the fundamental rule remains: never block the event loop. A single synchronous I/O call, a heavy computation, or a subprocess call can cascade into starvation across your entire application. The workaround most people discover too late is running blocking code in a thread pool executor with asyncio.to_thread. I had an application where a Redis client that didn't support async was called directly from an async handler. Latency spiked unpredictably because other coroutines were blocked waiting for the response. Moving that call through to_thread resolved the issue completely. Also, be aware that async functions run synchronously until the first await. This means setup code before an await doesn't yield control. If your initialization involves multiple async calls, make sure each one is awaited individually or wrapped in asyncio.gather.

Dependency Management That Doesn't Break Builds

Pip install freezes to requirements.txt is fine for simple projects. For anything with multiple environments or deployment targets, uv or pip-tools with pyproject.toml-based resolution gives you more deterministic outcomes. The problem with loose dependency specs is that a transitive dependency update can silently change behavior. I've seen numpy version bumps cause float comparison failures in numeric libraries that depended on subtle implementation details. Pin your dependencies at the minor version level at minimum. Use uv lock to generate a lockfile that captures the exact resolved graph. This takes about the same amount of time as pip install and gives you reproducibility across machines and CI runners.

Testing Strategy That Doesn't Involve Three Weeks of Setup

pytest is the default for a reason. The fixture system handles dependency injection cleanly, and parametrization covers edge cases without duplicating test code. The mistake most teams make is over-mocking. Mocking everything makes your tests fast but fragile. They pass when the mock is right and fail when the implementation changes in an unrelated way. Use real dependencies where possible. Integration tests with a lightweight database like SQLite or Testcontainers keep your tests closer to production behavior. Unit tests should focus on logic, not infrastructure. I stopped mocking database sessions entirely after moving to an in-memory SQLite instance with proper schema migrations. The tests caught foreign key constraint issues that mocks had hidden for months.

Caching That Doesn't Become Technical Debt

Caching is necessary. Caching without invalidation strategy is a bug waiting to happen. The most common failure mode I see is stale cache reads during feature flag changes or configuration updates. Implement cache keys that include version hashes or use cache busting strategies tied to deployment events. For application-level caching, functools.lru_cache is suitable for small, stable datasets. For distributed caching with Redis, be explicit about TTL and cache key design. A cache key that's just a function argument concatenation will collide under common input patterns. Use namespace prefixes and consider hashing input parameters. The hard truth about caching is that it solves performance problems and creates correctness problems. If your cache hit rate is below sixty percent, you're probably adding latency, not removing it. Monitor hit rates and adjust TTL values based on actual access patterns rather than guesswork.

Code Organization Beyond One Function Per File

Flat package structures work until they don't. Group related functionality by domain, not by type. A controllers/ directory with mixed concerns is worse than a services/ directory organized around business capabilities. I reorganized a codebase where API routes, background tasks, and data models were all interdependent in a flat hierarchy. Splitting them into domain packages with explicit internal imports reduced coupling and made the dependency graph readable. Use __init__.py files for public API exposure rather than leaking internal module paths. This gives you a single point to adjust what external consumers can import. It also makes refactoring safer since internal reorganizations don't break imports downstream. The hardest part of applying these practices consistently is getting team buy-in on tooling. Mypy, ruff, pre-commit hooks. These add friction initially but reduce it dramatically once the pipeline enforces them. The cost of enforcing style and type consistency through automation is far lower than the cost of reviewing those issues manually or debugging issues that proper tooling would catch immediately.

Grainy Texture PNGs for Free Download
Grainy Texture PNGs for Free Download