7 Python Best Practices Senior Developers Follow (That Beginners Often Miss)


Here’s a function most reviewers would wave through. It fetches some orders, calls an API, logs a line, returns a result, and every test on the happy path passes. It also builds its own HTTP client, waits on the network forever, and logs “processing failed” with no way to tell which job. And there’s no way at all to exercise what happens when the service goes down. A linter would pass it without a single complaint, because none of these problems are style problems.

If naming and formatting are still the concern, the clean code crash course covers that ground well. This list starts where local tidiness stops helping. Senior Python practice, watched up close, is mostly surprise reduction. These seven habits surface the surprises before production does.

Hidden Assumption Production Symptom Practice That Exposes It First Review Question
“The client will be there” untestable code, deep patching Pass dependencies in (typed as a Protocol) Could a test substitute this collaborator?
“Cleanup will happen eventually” leaked handles, locks held under load Context managers own resource lifetime Does cleanup run when the body raises?
“The service will answer” requests stuck forever, workers starved A deadline on every external wait What happens when this times out?
“We’ll know what happened” “processing failed”, no job ID Log events with investigable context Could on-call trace this line to a job?
“The happy path is the behavior” failures discovered in production Test the failure contract Which ugly inputs does the suite cover?
“Everyone knows how this runs” works-on-my-machine, CI surprises Declare metadata in pyproject.toml Which Python and deps does this assume?
“Nobody uses that old function” breaking change lands unannounced Deprecate before you delete Where is the caller’s migration path?
Seven hidden assumptions, the symptom each one produces in production, and the practice that makes it reviewable. Original reference table for this article.

1. Passing Dependencies In Instead of Hiding Them

Code is easier to test and to replace when the caller can see which collaborator it needs. The version that hides its dependency looks innocent: somewhere inside, the function constructs its own httpx.Client() and calls the real network. Every test then either hits the internet or patches deep into module internals. The fix is to accept the collaborator, typed as small as possible:

from typing import Protocol

class OrderClient(Protocol):
    def submit(self, payload: dict) -> dict: ...

def process_order(order: dict, client: OrderClient) -> str:
    response = client.submit(order)
    return response["status"]

typing.Protocol gives you structural typing, so any object with a matching submit method satisfies the interface for static checking, no inheritance tree required. One honest caveat: Protocol is a shape for the type checker, not runtime validation, so don’t expect it to reject a bad object at execution time. The payoff shows up immediately in tests, where a five-line fake that records its calls replaces the network entirely. No framework needed; when the set of collaborators grows past a handful, a registry pattern keeps the wiring explicit without one.

2. Letting Context Managers Own Resource Cleanup

Acquire and release in the same visible block, and let the block guarantee the release. That’s the entire job of with, and it covers more than files. Locks, database transactions, temporary directories, and any client with a context-manager API all belong inside one. When your own class owns setup and teardown, contextlib makes the pattern nearly free:

from contextlib import contextmanager
import tempfile, shutil

@contextmanager
def scratch_dir():
    path = tempfile.mkdtemp()
    try:
        yield path
    finally:
        shutil.rmtree(path)

The part that matters operationally is the failure case. If the body raises, cleanup still runs; raise an exception mid-block and check afterward, and the directory is gone all the same. Trusting garbage collection to close things eventually is not a cleanup strategy; it’s a cleanup lottery with bad odds under load.

3. Giving Every External Wait a Deadline

An unbounded wait is an undeclared failure mode, and most network calls ship with one by default. On Python 3.11 and later, asyncio.timeout() bounds an awaited operation cleanly, with the TimeoutError caught outside the block:

async def fetch_orders(client):
    try:
        async with asyncio.timeout(2.0):
            return await client.fetch()
    except TimeoutError:
        raise OrderFeedUnavailable("order feed timed out after 2s")

Synchronous clients don’t get this for free, and that’s the real point of the practice. Each HTTP, database, or queue library needs its own supported timeout mechanism configured. The habit is the deadline plus a decided response, not one universal function. And the expiry deserves an actual decision.

Read Also:  All About Google Colab File Management

Retry only when the operation is safe to repeat and the error looks transient. Otherwise fall back, return a partial result where the product allows it, or fail loudly with enough context to investigate. The earlier KDnuggets look at decorators for robust agents builds the retry-and-fallback side out further if you want the deeper treatment.

4. Logging Events With the Context Needed to Investigate Them

“Processing failed” isn’t much of an operational record. It tells you something, somewhere, once went wrong. The standard library already supports better without any structured-logging dependency:

log.info("import finished", extra={"job_id": "j-193", "records": 4211})

With a formatter that includes those fields, the line that comes out reads import finished job=j-193 records=4211. That’s the difference between grepping a job ID and interrogating whoever was on call. For a set of related calls that share context, the logging cookbook’s LoggerAdapter pattern attaches the fields once instead of at every call site. One boundary holds firm: stable event names and useful fields, never tokens, passwords, or sensitive payloads. Structured logging makes leaking them more convenient too.

5. Testing the Failure Contract, Not Only the Happy Path

A passing test with one friendly input says almost nothing about how a boundary behaves under pressure, and this is the practice that turns the previous four from aspirations into enforcement. Parametrization covers the ugly inputs without cloning test bodies:

@pytest.mark.parametrize("raw", ["", "   ", None])
def test_rejects_missing(raw):
    with pytest.raises(ValueError, match="required"):
        parse_amount(raw)

For the external pieces, monkeypatch swaps an environment variable, attribute, or collaborator for one test and restores it afterward. A test can then force the timeout path or the malformed-response path on demand. The assertions deserve as much thought as the setup. Assert behavior a caller can observe — meaning the right exception, the warning, the fallback value, the log field, the cleanup action. Tests that assert every internal call in sequence don’t verify the contract; they laminate the implementation, and they shatter on the first harmless refactor.

Read Also:  Unlock Business Value: Build a Data & Analytics Strategy That Delivers

This article’s examples run as a real pytest suite, eight tests across the missing-input, boundary, environment-override, and swapped-collaborator cases. The whole run finishes in a hundredth of a second, which removes the last excuse for skipping the unhappy paths.

6. Treating Package Metadata as Part of the Code Contract

A project should say how it builds, what it depends on, and which Python versions it supports, in a file a machine can read. That file is pyproject.toml, and its three tables split the job: [build-system] for how the package builds, [project] for metadata including requires-python and dependencies, and [tool] for tool configuration.

A new contributor or a CI job can then inspect the runtime assumptions instead of reverse-engineering them from imports and tribal knowledge. Keep one distinction straight, though. Declaring httpx>=0.27 states an assumption; it does not lock an application to exact resolved versions, and pretending otherwise is how two “identical” environments drift apart. Locking is a separate tool and workflow decision.

7. Deprecating Public Behavior Before You Delete It

Compatibility is a change-management problem, and the standard library gives you the mechanics for managing it:

def fetch_all(*args, **kwargs):
    warnings.warn(
        "fetch_all() is deprecated; use fetch_page()",
        DeprecationWarning, stacklevel=2,
    )

The stacklevel=2 matters because it points the warning at the caller’s line rather than yours. The message should always name the replacement. Now the caveat that surprises almost everyone: Python normally hides DeprecationWarning outside __main__, so library users may never see it. Surface it deliberately, in release notes and in test configuration. A single filterwarnings = ["error::DeprecationWarning"] line in the [tool.pytest.ini_options] table turns silent deprecations into failing tests. That’s exactly where you want to meet them.

The warnings documentation covers the filter mechanics. The sequence stays boring on purpose: ship the replacement, warn on the old path, document the migration, watch remaining usage where you can, and only then remove it in a planned release.

The Senior Habit Is Making Assumptions Reviewable

All seven practices collapse into one pull-request question, which is where they earn their keep.

Where does this code wait, and for how long?

What does it depend on, and could a test substitute that dependency?

What will the log line tell whoever is on call at 2 a.m., what happens when the boundary misbehaves, which Python does it assume, and which caller-visible contract just changed?

None of these habits add ceremony for its own sake. Each one moves an assumption from someone’s head into a place where another developer, a test, or an operator can see it. Code that shows its assumptions is the code that survives being maintained.

 
 

Nahla Davies is a software developer and tech writer. Before devoting her work full time to technical writing, she managed—among other intriguing things—to serve as a lead programmer at an Inc. 5,000 experiential branding organization whose clients include Samsung, Time Warner, Netflix, and Sony.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top