# The Seven Python Practices That Separate Senior Engineers from the Rest
Most Python code that reaches production has something in common: it looks fine. Formatting is clean. Variables are named. Unit tests pass on the developer’s machine. The problem is that “looking fine” is a very low bar, and it misses the hidden assumptions lurking beneath every function signature, every import, and every `print()` statement that someone later turns into a log line.
What senior developers do differently is make those assumptions explicit — so they can be challenged, tested, and removed before they become outages at 3 a.m.
Here are seven practices that, taken together, form a defensive programming mindset worth adopting at any stage of your career.
—
## 1. Accept Dependencies Instead of Building Them Internally
When a function secretly constructs its own HTTP client, database connection, or file handler, it quietly eliminates every path to testing that behavior without heavy patching. The fix is structural: pass the dependency in as an argument and type it with a `Protocol` so that any compatible object satisfies the requirement.
“`python
from typing import Protocol
class PaymentGateway(Protocol):
def charge(self, amount: float) -> str: …
def process_payment(order: dict, gateway: PaymentGateway) -> str:
return gateway.charge(order[“total”])
“`
A `Protocol` is a structural contract — it says “if it walks like a duck and quacks like a duck, it’s a duck.” No base class, no framework, no ceremony. The real benefit emerges in test suites: a tiny fake object that records every call replaces the network entirely, and every test runs in milliseconds. When teams grow past a handful of collaborators, a registry pattern keeps the wiring explicit without introducing unnecessary abstraction layers.
One important caveat: `Protocol` is purely a static-checking construct. It will not raise errors at runtime if you pass an incompatible object. Treat it as documentation that tools can verify, not as enforcement that Python will guarantee.
—
## 2. Let Context Managers Guarantee Resource Lifetimes
The `with` statement exists for one reason: to pair acquisition and release in a single visible block that no exception can break. Files, locks, database transactions, temporary directories, and any object exposing the context-manager protocol all belong inside one. When you need to wrap your own class, the `contextlib` module makes this nearly trivial.
“`python
from contextlib import contextmanager
import tempfile, shutil
@contextmanager
def temporary_workspace():
path = tempfile.mkdtemp()
try:
yield path
finally:
shutil.rmtree(path)
“`
The critical thing to internalize is what happens on failure. Raise an exception anywhere inside the block, and the `finally` clause still runs. The directory is cleaned up regardless. Relying on the garbage collector to close resources eventually is not a strategy — it is a gamble, and under load the odds of a leaked file descriptor or a stuck lock grow quickly.
A useful sanity check: if you can imagine a failure mid-block and your cleanup does not run, the pattern is incomplete.
—
## 3. Put a Hard Limit on Every External Wait
An operation that waits forever has quietly declared a new, undocumented failure mode. Network calls, database queries, message queue pulls — all of these need timeouts configured explicitly, and each library has its own mechanism for it. Synchronous clients especially do not get sensible defaults by default.
On Python 3.11 and newer, `asyncio.timeout()` provides a clean way to bound an awaited operation:
“`python
import asyncio
async def fetch_data(client):
try:
async with asyncio.timeout(5.0):
return await client.get(“/data”)
except TimeoutError:
raise ServiceUnavailable(“upstream call exceeded 5 second limit”)
“`
But timeouts are more than a function call — they are a decision. When a deadline is hit, what should happen? Retry immediately? Return partial data? Fail loudly with enough context for an operator to act? Each choice depends on what the downstream service guarantees and how critical the operation is.
Resist the temptation to retry blindly. Only repeat an operation if it is safe to do so and the error genuinely looks transient. Otherwise, surface the failure with the maximum useful detail and let the system degrade gracefully rather than cascading.
—
## 4. Make Every Log Line an Investigation Starting Point
The message `”processing failed”` is a cry for help with no context attached. Which job? Which record? Which request? The person reading it at 2 a.m. has no way to trace it back to the source.
The standard library’s `logging` module supports attaching structured context to every record via the `extra` parameter. A formatter can then weave those fields into the output naturally:
“`python
log.info(“batch completed”, extra={“job_id”: “b-419”, “items_processed”: 3800})
“`
This produces output that reads like `batch completed job=b-419 items_processed=3800`, which is searchable, grep-able, and actionable. For repeated context across a set of related calls, the `LoggerAdapter` pattern from the logging cookbook attaches the fields once at the boundary instead of repeating them at every call site.
One boundary worth drawing: log data is a tool for human and machine investigation, never a dump site for tokens, passwords, or sensitive payloads. Structured logging makes leaking those things more convenient, not less, so treat every field as something that will eventually appear in a dashboard or an alert.
—
## 5. Test What Goes Wrong, Not Just What Goes Right
A happy-path test proves that the code works when everything cooperates. That is useful, but it is also a very small fraction of the picture. Parametrization lets you cover the ugly, messy, and unexpected inputs without writing a separate test function for each one:
“`python
import pytest
@pytest.mark.parametrize(“value”, [“”, ” “, None, -1])
def test_rejects_invalid_input(value):
with pytest.raises(ValueError, match=”required”):
parse_quantity(value)
“`
For external collaborators, `monkeypatch` in pytest lets you swap environment variables, module attributes, or even entire service clients for a single test and restore them afterward. This unlocks failure paths — timeouts, malformed responses, permission errors — that are otherwise invisible to your suite.
The assertions matter as much as the setup. Test for observable behavior: the exception type raised, the fallback value returned, the warning emitted, the cleanup action performed. Tests that assert every internal function call in sequence do not verify behavior; they laminate the implementation to a specific structure, and the first harmless refactor breaks them.
A well-written failure suite runs fast — in the space of a second or two — which removes every remaining excuse for skipping it.
—
## 6. Treat Metadata Files as Part of the Code
A project is not just its source code. It is also the machine-readable declaration of how it builds, what it requires, and which Python versions it supports. The `pyproject.toml` file has become the standard place for this, organized into three clear sections: the `[build-system]` table declares the build backend, the `[project]` table declares metadata including `requires-python` and `dependencies`, and the `[tool]` table keeps tool-specific configuration separate.
A new contributor or a fresh CI job can inspect this file and immediately understand the runtime assumptions without reverse-engineering imports or tribal knowledge. Declaring a dependency like `httpx>=0.27` states an assumption about compatibility. It does not lock the application to an exact resolved version. Pinning exact versions and their transitive dependencies is a separate workflow decision, typically handled by a lockfile tool, and conflating the two is a common source of “works on my machine” surprises.
Keep the distinction clear: `pyproject.toml` declares what the code needs to function. A lockfile records what the environment actually resolved. Both have a place, but they serve different audiences.
—
## 7. Warn Before You Remove
Deleting a public function is a breaking change, and the best way to manage it is through a deliberate deprecation cycle. The standard library’s `warnings` module gives you the mechanics:
“`python
import warnings
def legacy_fetch(*args, **kwargs):
warnings.warn(
“legacy_fetch() is deprecated; use fetch() instead”,
DeprecationWarning,
stacklevel=2,
)
“`
The `stacklevel=2` parameter is critical because it points the warning at the call site in the user’s code rather than at the deprecation line in your own library. The message should always name the replacement, so the user knows exactly what to do next.
There is a gotcha that catches almost everyone: Python silences `DeprecationWarning` outside of `__main__` by default. Library users will never see your warning unless you surface it deliberately. Release notes, documentation, and test configuration are all tools for this. A single line in your pytest configuration:
“`ini
[tool.pytest.ini_options]
filterwarnings = [“error::DeprecationWarning”]
“`
…turns every silent deprecation into a failing test, which is exactly where you want it before shipping a release that removes the old behavior.
The sequence should always be: ship the replacement first, warn on the old path, document the migration, monitor usage where possible, and only remove it in a version where you can reasonably expect no remaining callers.
—
## FAQ
**Q: Why does it matter whether dependencies are passed in or hidden inside a function?**
A: Hidden dependencies are untestable without deep patching into module internals. When dependencies are visible at the call site, any collaborator — including a fake, a mock, or a stub — can be supplied at test time. The function becomes a pure unit of logic rather than a tight coupling to the outside world.
**Q: Are context managers only useful for file handling?**
A: Not at all. Context managers apply to any resource with a clear acquisition and release lifecycle: locks, database transactions, temporary directories, network client sessions, thread pools, and even custom classes you build yourself. If you have a `setup` and a `teardown`, the context manager protocol exists for a reason.
**Q: What should the timeout value actually be?**
A: There is no universal answer. The right value depends on the user experience expectations, the downstream service’s typical response time, and the business impact of waiting. The practice is not choosing a number — it is making the decision explicit and documenting the reasoning so future engineers can revisit it.
**Q: Can I log sensitive data if I mask it first?**
A: Masking is better than logging raw values, but masking is also a form of transformation that can be buggy or inconsistent. The safest practice is to never include sensitive data in log records at all. Use reference IDs, hashes, or tokens that can be traced back externally through a secure lookup if needed.
**Q: Should I test private methods directly?**
A: Generally no. If a private method needs its own test, it is often a signal that it should be extracted into a separate, public module or class. The boundary between public and private is there for a reason — testing private methods couples your test suite to implementation details that will change during refactoring.
**Q: How long should a deprecation period last?**
A: It depends on your release cadence and user base. For libraries, a minimum of two major or minor releases is common. For internal applications, one release cycle plus a written announcement is often sufficient. The key is giving users enough time and enough notice to update their code before the old behavior disappears.
**Q: What if my project doesn’t use pyproject.toml yet?**
A: Migration is straightforward. Start by moving the build requirements and Python version constraints into a `[project]` table. Move tool configurations into `[tool]` tables. Most modern packaging tools — pip, setuptools, poetry, hatchling — all recognize and prefer `pyproject.toml` as the single source of truth for project metadata.
—
## Conclusion
The gap between code that looks correct and code that behaves correctly under pressure is where most production incidents originate. None of these seven practices add ceremony for its own sake. Each one takes an assumption — about a collaborator, a resource, a timeout, a log message, an error path, a dependency version, or a public API — and moves it from someone’s head into a place where another developer, a test, or an operator can see it, challenge it, and act on it.
Code that reveals its assumptions survives being maintained, extended, and handed off. Code that hides them surprises everyone eventually. The choice of which kind of code to write is made not at the point of crisis, but at every single pull request along the way.
Thank you for reading



