← Back to Learning Hub

Python Best Practices

PythonTestingBeginner25 min

By: Anacodic Team

TL;DR — Four habits, each justified by a specific class of bug it catches. Type hints are ignored at runtime — Python does not check them — so their entire value is the static checker you run in CI; in the example below, passing a dict where a list[float] was expected produces a plausible answer on the first call and a TypeError on the second, and mypy reports it without running anything. Tests matter less for confirming what works than for forcing you to name the edge case: writing the "empty input" case is what surfaces the ZeroDivisionError that was always there. Logging beats print because it has levels, so a DEBUG line can ship to production switched off and be switched on without a redeploy — and it carries the module name, so you know which component spoke. Pinning exact versions is what makes "it worked last week" reproducible; a >=1.20 constraint on numpy silently accepted a 2.x major release that changed the API. None of these is about elegance. Each converts a silent production failure into a loud, local one.


1. Simple explanation

Working code and maintainable code are different targets, and the gap between them is almost entirely about how failures behave.

Code that merely works fails silently. It returns a plausible number, logs nothing useful, and behaves differently on your laptop than on the server. You find out weeks later, from a user, and the debugging starts from zero.

Code written with these four habits fails loudly and early. A type checker catches the wrong argument before the code merges. A test catches the empty-input case before a user finds it. A log line at the right level tells you which component failed and with what. A lockfile means the version that broke is the version you can reproduce.

Each habit costs something small and specific: a few characters of annotation, a test function, a logger call instead of print, an exact version instead of a range. Each buys the same thing — the failure moves from production to your terminal.

Analogy — a recipe versus a checked recipe. Any recipe that says "add flour" will produce something. A recipe that says "add 240 g plain flour" catches the person reaching for bread flour, and one that adds "the batter should ribbon off the spoon; if it does not, you have over-mixed" catches the failure at the point where it can still be fixed. Neither addition changes the cake. Both change how many attempts it takes to get one, and what happens when someone else follows it in a different kitchen.

That last clause is the point. All four habits are about the code working for someone else, at another time, on another machine — including you in six months.


2. Diagram

WHERE EACH HABIT CATCHES THE BUG

  write ──► commit ──► CI ──► deploy ──► production ──► user reports it
    │         │         │                    │              │
    │         │         │                    │              └─ no habits:
    │         │         │                    │                 debugging from
    │         │         │                    │                 zero, weeks later
    │         │         │                    └─ LOGGING: which component,
    │         │         │                       what level, what values
    │         │         └─ TESTS + TYPE CHECK: the bug never merges
    │         └─ PINNING: the build is the same one that passed CI
    └─ TYPE HINTS: your editor flags it as you type

     cost of a fix:   1x        10x        100x       1000x


TYPE HINTS -- checked statically, ignored at runtime

  def average_score(scores: list[float]) -> float:
      return sum(scores) / len(scores)

  average_score([0.9, 0.74])    ✓  0.8200000000000001
  average_score({"d1": 0.9})    ← Python runs it happily; sum() adds the
                                   KEYS; TypeError three frames later

  mypy, without executing anything:
    error: Argument 1 has incompatible type "dict[str, float]";
           expected "list[float]"


LOGGING vs PRINT

  print("got 3 docs")           logging
  ────────────────────          ─────────────────────────────────────
  always on                     level-filtered: DEBUG off in prod,
  no timestamp                    ON without a redeploy
  no source                     name tells you WHICH module
  stdout only                   stdout, file, syslog, JSON collector
  delete to remove              one config line to silence

  log.setLevel(INFO):   DEBUG   ✗ suppressed (still in the code)
                        INFO    ✓ retrieved 3 docs in 42ms
                        WARNING ✓ top score 0.12 below threshold
                        ERROR   ✓ vector store unreachable


PINNING -- the same requirements file, six months apart

  spec          Mar install   Sep install   consequence
  ───────────────────────────────────────────────────────────
  >=2.0         2.31.0        2.32.3        drifts, usually fine
  >=1.20        1.24.0        2.1.0         MAJOR bump, API changed  ✗
  (unpinned)    4.38.0        4.44.0        anything at all          ✗
  ==2.2.1       2.2.1         2.2.1         reproducible             ✓

3. How it works

Type hints

Annotations are stored in __annotations__ and otherwise ignored. def f(x: int) -> str does not check anything; passing a list works fine until something inside the body objects. This surprises people, and it is the key to using hints correctly: they are documentation that a tool can verify, and if you never run the tool you have only the documentation.

The tools are mypy and pyright (the latter powers Pylance in VS Code, which is why annotated code gets better autocomplete). Both perform static analysis: they follow types through calls and report mismatches without executing anything. Run one in CI or the hints are decorative.

What to annotate, in priority order: function signatures at module boundaries, because that is where callers get it wrong; anything returning Optional, because the checker will then force you to handle None; and container element types (list[float], not bare list), because the element type is what actually catches bugs. Local variables rarely need annotating — inference handles them.

Adopting hints on an existing codebase is incremental. Start with mypy --ignore-missing-imports on one module, fix what it finds, and enable --strict module by module. # type: ignore[code] on a line silences a specific error where a library's stubs are wrong, and being specific about the code prevents it from hiding a real error later.

Tests

The mechanical value of a test is regression protection. The larger value, especially early, is that writing the test forces you to enumerate inputs, and enumeration is where you notice the empty list, the zero, the None, the duplicate key.

pytest is the standard. A test is a function starting with test_, and the assertion is a plain assert — pytest rewrites it to show both operand values on failure. Parametrise rather than copy:

@pytest.mark.parametrize("scores,expected", [
    ([2.0, 2.0, 4.0], [0.25, 0.25, 0.5]),
    ([0.0, 0.0],      [0.0, 0.0]),           # the case that found the bug
    ([5.0],           [1.0]),
])
def test_normalise(scores, expected):
    assert normalise(scores) == pytest.approx(expected)

Note pytest.approx — floats, so equality needs a tolerance, exactly as Python Fundamentals covers.

Coverage percentage is a weak proxy. 100% coverage with no assertions about edge cases proves only that every line executed. The useful question is not "what fraction of lines run" but "which inputs would break this," and the answer to that goes in the parametrize list.

Logging

logging gives you five levels (DEBUG, INFO, WARNING, ERROR, CRITICAL), a logger name per module, and configurable output destinations. The level filter is the feature that matters: a DEBUG line costs almost nothing when suppressed and can be enabled in production by changing configuration rather than code.

The conventions worth following:

logger = logging.getLogger(__name__) at module top. The name propagates to the output, so you can see which component logged, and you can set levels per package — DEBUG for your retrieval module, WARNING for a noisy library.

Use log.info("retrieved %d docs in %dms", n, ms), not an f-string. With %s formatting, the interpolation only happens if the record is actually emitted; with an f-string it happens always, even for a suppressed DEBUG line. This is genuine overhead in a hot loop and it is the reason the library's API looks dated.

Use log.exception("...") inside an except block. It logs at ERROR and attaches the traceback automatically, which log.error does not.

Libraries should never configure logging — only add a handler in the application entry point. A library that calls logging.basicConfig() hijacks its host's configuration.

Structured (JSON) logging is what makes logs queryable at scale; see LLM Observability for what fields to emit when the thing being logged is a model call.

Pinning

Two files, two jobs. pyproject.toml (or requirements.in) declares what you need, with loose ranges. A lockfile — requirements.txt from pip-compile, poetry.lock, or uv.lock — records exactly what was installed, including transitive dependencies and hashes. You commit both. CI installs from the lockfile, so it tests what you will deploy.

Without a lockfile, pip install -r requirements.txt resolves fresh every time. Two developers, two CI runs, and production all get different transitive versions, and the difference surfaces as a bug nobody can reproduce.

Semantic versioning says >=1.20 should be safe until 2.0, and >=1.20 permits 2.0 — that is the numpy row in the table, and it is the single most common instance of this failure. >=1.20,<2 expresses the intent. Note that semver is a convention libraries follow imperfectly; breaking changes ship in minor releases regularly, which is the argument for the lockfile rather than for more careful ranges.

The one asymmetry: applications pin exactly, libraries use ranges. A library with ==2.2.1 cannot be installed alongside anything else that needs numpy, making it useless as a dependency.


4. The math

The quantitative argument for all four habits is the cost of a defect as a function of when it is found.

Let $c(s)$ be the cost of fixing a defect discovered at stage $s$. The empirical finding, consistent across four decades of software engineering research (Boehm 1981; the NIST 2002 economic study; Boehm & Basili's "Software Defect Reduction Top 10"), is roughly geometric:

$$ c(s) \approx c_0 \cdot k^{s}, \qquad k \approx 5\text{--}10 $$

with stages ordered: writing → review → CI → deploy → production. A defect that costs 1 unit to fix while typing costs on the order of $10^3$ units once a user hits it — because that cost now includes reproduction, triage across people who did not write the code, a hotfix, a deploy, and whatever the wrong answer caused downstream.

Each habit moves defects left by some number of stages. If a habit catches a fraction $p$ of defects $d$ stages earlier, the expected saving per defect is:

$$ E[\text{saving}] = p \cdot c_0 \left(k^{s} - k^{s-d}\right) = p \cdot c(s)\left(1 - k^{-d}\right) $$

The useful property of that expression is that $\left(1 - k^{-d}\right)$ saturates quickly. At $k = 8$: moving a defect one stage earlier captures 87.5% of the available saving; two stages captures 98%. You do not need a habit to be comprehensive to be worth it — catching a modest $p$ one stage earlier already dominates its cost, which is why a partial mypy adoption or a handful of parametrized tests pays off immediately rather than only once complete.

The pinning case has a different shape, because the failure is not a defect rate but a reproduction probability. With $n$ unpinned direct and transitive dependencies, each independently releasing an incompatible version with probability $q$ over a period, the chance that a rebuild reproduces the environment is:

$$ P(\text{reproducible}) = (1 - q)^{n} $$

Modern projects have large $n$ — a typical ML service resolves to 150–300 packages. Even at a small per-package $q = 0.005$, with $n = 200$:

$$ P(\text{reproducible}) = (1 - 0.005)^{200} = 0.995^{200} \approx 0.37 $$

A 63% chance that a rebuild six months later differs from what passed CI. A lockfile sets $q = 0$ by construction, giving $P = 1$.

That is the honest form of "it worked last week." It was not bad luck; with an unpinned tree of that size it was the likely outcome.


5. Real code

"""Type hints, tests, logging, pinning -- four habits, each shown catching a real bug."""
import io
import logging
import subprocess
import sys
import tempfile
from pathlib import Path

# ---- 1. type hints catch a bug that runs fine until it doesn't ----------
SRC = '''
def average_score(scores: list[float]) -> float:
    return sum(scores) / len(scores)

# Looks fine. Runs fine.
print(average_score([0.9, 0.74]))

# This is the bug: a dict of results, not a list of floats.
results = {"doc1": 0.9, "doc2": 0.74}
print(average_score(results))          # sums the KEYS -> TypeError at runtime
'''
print("1. TYPE HINTS -- a static checker finds this before it ships")
tmp = Path(tempfile.mkdtemp()) / "scoring.py"
tmp.write_text(SRC)
proc = subprocess.run([sys.executable, str(tmp)], capture_output=True, text=True)
print(f"   running it     : first call prints {proc.stdout.strip().splitlines()[0]}")
print(f"   then it crashes: {proc.stderr.strip().splitlines()[-1]}")
print("   A checker (mypy/pyright) reports the SAME bug without running anything:")
print('   error: Argument 1 has incompatible type "dict[str, float]";'
      ' expected "list[float]"')
print("   Hints are not enforced at runtime -- Python ignores them. Their value")
print("   is entirely in the checker you run in CI.")

# ---- 2. a test pins behaviour you'd otherwise break silently ------------
def normalise(scores):
    """Scale scores to sum to 1."""
    total = sum(scores)
    if total == 0:
        return [0.0] * len(scores)        # the edge case a test forces you to face
    return [s / total for s in scores]


print("\n2. TESTS -- the edge case you only think about when writing one")
cases = [
    ("ordinary",     [2.0, 2.0, 4.0], [0.25, 0.25, 0.5]),
    ("all zero",     [0.0, 0.0],      [0.0, 0.0]),
    ("single item",  [5.0],           [1.0]),
]
for name, inp, want in cases:
    got = normalise(inp)
    ok = all(abs(a - b) < 1e-9 for a, b in zip(got, want))
    print(f"   {name:<12} {str(inp):<18} -> {got}  {'PASS' if ok else 'FAIL'}")
print("   Without the `if total == 0` guard the second case is ZeroDivisionError.")
print("   In pytest this is one parametrized function; the point is that writing")
print("   the test is what surfaced the empty/zero case at all.")

# ---- 3. logging vs print: levels, and where output goes -----------------
print("\n3. LOGGING vs PRINT -- print has no level, no timestamp, no off switch")
stream = io.StringIO()
handler = logging.StreamHandler(stream)
handler.setFormatter(logging.Formatter("%(levelname)-8s %(name)s: %(message)s"))
log = logging.getLogger("retrieval")
log.handlers[:] = [handler]
log.setLevel(logging.INFO)                 # DEBUG lines are dropped, not deleted

log.debug("scores=[0.91, 0.74, 0.12]")     # suppressed at INFO
log.info("retrieved 3 docs in 42ms")
log.warning("top score 0.12 below threshold 0.5")
log.error("vector store unreachable, falling back to keyword search")
for line in stream.getvalue().strip().splitlines():
    print(f"   {line}")
print("   The DEBUG line is missing -- filtered by level, and recoverable in")
print("   production by changing one setting. A print() you have to delete and")
print("   redeploy. Logs also carry the module name, so you know WHO said it.")

# ---- 4. pinning: the same requirements file, two different installs -----
print("\n4. PINNING -- 'it worked last week' is usually this")
rows = [
    ("requests",     ">=2.0",      "2.31.0", "2.32.3", "floats forward, silently"),
    ("numpy",        ">=1.20",     "1.24.0", "2.1.0",  "MAJOR bump, API changed"),
    ("transformers", "(unpinned)", "4.38.0", "4.44.0", "anything at all"),
    ("torch",        "==2.2.1",    "2.2.1",  "2.2.1",  "reproducible"),
]
print(f"   {'package':<14} {'spec':<12} {'Mar':<8} {'Sep':<8} what happens")
for p, spec, a, b, note in rows:
    print(f"   {p:<14} {spec:<12} {a:<8} {b:<8} {note}")
print("   Pin exact versions in a lockfile; keep ranges only in the library you")
print("   publish. The numpy row is the one that actually breaks builds.")

assert proc.returncode != 0 and "TypeError" in proc.stderr
assert normalise([0.0, 0.0]) == [0.0, 0.0]          # no ZeroDivisionError
assert abs(sum(normalise([2.0, 2.0, 4.0])) - 1.0) < 1e-9
assert "DEBUG" not in stream.getvalue()              # level filtering works
assert "WARNING" in stream.getvalue() and "retrieval" in stream.getvalue()
print("\nall assertions passed")

Output:

1. TYPE HINTS -- a static checker finds this before it ships
   running it     : first call prints 0.8200000000000001
   then it crashes: TypeError: unsupported operand type(s) for +: 'int' and 'str'
   A checker (mypy/pyright) reports the SAME bug without running anything:
   error: Argument 1 has incompatible type "dict[str, float]"; expected "list[float]"
   Hints are not enforced at runtime -- Python ignores them. Their value
   is entirely in the checker you run in CI.

2. TESTS -- the edge case you only think about when writing one
   ordinary     [2.0, 2.0, 4.0]    -> [0.25, 0.25, 0.5]  PASS
   all zero     [0.0, 0.0]         -> [0.0, 0.0]  PASS
   single item  [5.0]              -> [1.0]  PASS
   Without the `if total == 0` guard the second case is ZeroDivisionError.
   In pytest this is one parametrized function; the point is that writing
   the test is what surfaced the empty/zero case at all.

3. LOGGING vs PRINT -- print has no level, no timestamp, no off switch
   INFO     retrieval: retrieved 3 docs in 42ms
   WARNING  retrieval: top score 0.12 below threshold 0.5
   ERROR    retrieval: vector store unreachable, falling back to keyword search
   The DEBUG line is missing -- filtered by level, and recoverable in
   production by changing one setting. A print() you have to delete and
   redeploy. Logs also carry the module name, so you know WHO said it.

4. PINNING -- 'it worked last week' is usually this
   package        spec         Mar      Sep      what happens
   requests       >=2.0        2.31.0   2.32.3   floats forward, silently
   numpy          >=1.20       1.24.0   2.1.0    MAJOR bump, API changed
   transformers   (unpinned)   4.38.0   4.44.0   anything at all
   torch          ==2.2.1      2.2.1    2.2.1    reproducible
   Pin exact versions in a lockfile; keep ranges only in the library you
   publish. The numpy row is the one that actually breaks builds.

all assertions passed

Three things in that output are the actual lesson.

The type error surfaces in the wrong place. The reported message is unsupported operand type(s) for +: 'int' and 'str' — a complaint from inside sum, about adding the string key "doc1" to the integer accumulator. Nothing in it mentions average_score, the caller, or a dict. The checker's message names the argument, the actual type, and the expected type. That difference — an error at the boundary versus a symptom three frames deep — is the whole return on annotating.

The first call printed 0.8200000000000001, not 0.82. The function is correct there; the float is doing what floats do. It appears in the output as a reminder that the assertion in a test needs pytest.approx even when the code under test is right.

DEBUG is absent from the log output, and the line is still in the source. It was not deleted; it was filtered. The scores that would tell you why retrieval returned the wrong document are one configuration change away, in a running process, without a deploy. A print cannot be switched off, so under pressure it gets deleted, and then it is not there next time.


6. Real-world example

A retrieval service, six weeks old, with none of the four habits. The bug report is: "sometimes the assistant answers from the wrong document."

def retrieve(query, k=5):
    hits = index.search(query, k)
    print(f"got {len(hits)} hits")
    return [h for h in hits if h.score > THRESHOLD]

Debugging this is guesswork. print output goes to a container's stdout that nobody collects, and it does not say which query, which tenant, or what the scores were. There is no test pinning what retrieve should do when index.search returns fewer than k results, or none. THRESHOLD is a module-level float with no annotation, so nobody notices that a config loader is handing it a string — and 0.4 > "0.5" raises on Python 3, while h.score > THRESHOLD with THRESHOLD = None raises differently again. And the sentence-transformers version drifted two minor releases last month, which changed the default normalisation and shifted every score by a constant, which is the actual root cause.

Diagnosis takes two days and ends with a git bisect over dependency versions.

The same function with the four habits:

logger = logging.getLogger(__name__)

def retrieve(query: str, k: int = 5) -> list[Hit]:
    hits = index.search(query, k)
    kept = [h for h in hits if h.score > SCORE_THRESHOLD]
    logger.info(
        "retrieval query_len=%d k=%d returned=%d kept=%d top_score=%.4f",
        len(query), k, len(hits), len(kept),
        hits[0].score if hits else float("nan"),
    )
    if hits and not kept:
        logger.warning("all %d hits below threshold %.2f", len(hits), SCORE_THRESHOLD)
    return kept

with SCORE_THRESHOLD: float = 0.5 annotated (so the checker rejects the string from the config loader at the assignment), a parametrized test covering zero hits, all-below-threshold, and exactly-at-threshold, and sentence-transformers==2.7.0 in a committed lockfile.

Now the same incident is a log query. top_score is in every line, so a shift in the score distribution is visible on a chart rather than inferred. The WARNING on "hits found but all filtered out" fires precisely when the symptom occurs — that branch is the wrong-document bug, and it announces itself. The lockfile means the version did not drift in the first place, and if it does drift deliberately, the score change shows up in the test that pins the at-threshold case.

Note the log line is one call with structured fields rather than four prints. Field-per-value is what makes it aggregatable — see LLM Observability for the full set of fields worth emitting around a model call, and Vector Search for Retrieval for why the threshold is the parameter most worth logging.


7. Interview questions companies actually ask

"Are Python type hints enforced at runtime?" No. They are stored in __annotations__ and otherwise ignored; the value comes entirely from a static checker like mypy or pyright, plus editor autocomplete. Strong answer: some libraries (Pydantic, FastAPI) do read annotations and validate at runtime, but that is the library's behaviour, not the language's.

"What is the difference between logging and print?" Levels, per-module logger names, configurable handlers and formats, and the ability to change verbosity without changing code. Strong answer adds the %s-versus-f-string point: %-style defers interpolation until the record is actually emitted, so a suppressed DEBUG line costs almost nothing.

"How do you make a build reproducible?" A lockfile with exact versions and hashes for the full transitive tree, committed to the repo, with CI installing from it. Strong answer: applications pin exactly, libraries use ranges — a library pinned to == is uninstallable alongside anything else.

"What should you test?" Edge cases and boundaries: empty, zero, one, None, duplicates, the exact threshold value. Strong answer names why: writing the test is what makes you enumerate them, and enumeration is where the bug is found — the regression protection is a secondary benefit.

"Is 100% coverage a good goal?" No. Coverage measures which lines executed, not whether anything was asserted about them. A test suite with full coverage and no edge cases proves very little. Better signals are mutation testing and whether the cases in the parametrize list look like real inputs.

"How would you introduce type hints into a large untyped codebase?" Incrementally. Start with mypy --ignore-missing-imports on the most-depended-upon module, annotate public signatures first, tighten to --strict per module, and use targeted # type: ignore[code] rather than blanket ignores.


8. When to use / tradeoffs

Type hints — always on public function signatures and module boundaries; optional on locals, where inference is fine. The cost is real: annotations on heavily generic code get verbose, and a checker running --strict on a codebase using dynamic patterns produces noise that trains people to ignore it. Adopt gradually and keep the signal high. For a one-off script, skip them.

Tests — always for logic with branches, arithmetic, or parsing; always for anything that has broken once. Not worth it for thin pass-through wrappers or code that only calls a library. The real cost is maintenance: tests coupled to implementation details break on every refactor and get deleted, which is worse than not having written them. Test behaviour at the boundary, not internals.

Logging — always in library and service code. Use DEBUG freely; it costs nothing when off. The failure mode is volume: INFO inside a hot loop produces gigabytes and a bill, and buries the lines that matter. Log per-request, not per-item, and sample high-cardinality events.

Pinning — always for applications and services. Ranges for published libraries. The cost is maintenance overhead: a locked tree needs deliberate updating, and left alone for a year it becomes a security liability. dependabot or renovate with CI gating is the standard resolution — automated bumps that must pass tests.

The tradeoff running through all four is effort now against debugging time later, and the honest version is that the exchange rate depends on how long the code lives. For a notebook you will run twice, all four are overhead. For anything with users, another maintainer, or a life measured in months, the geometric cost curve in §4 settles it — one stage earlier captures most of the available saving, which is why partial adoption is still clearly worth doing.


Type hints are ignored by Python and checked by mypy, so they pay off only if the checker runs in CI — and what they buy is an error naming the argument at the boundary instead of a TypeError three frames deep. Tests matter most for the enumeration they force: the empty-input case that becomes a ZeroDivisionError is found while writing the parametrize list, not in production. Logging beats print on levels, source, and destination, and the level filter is what lets diagnostic detail ship switched off. Pinning is the difference between a build you can reproduce and one you cannot, and with a few hundred transitive dependencies the probability of drift is not small.

All four convert silent, delayed, remote failures into loud, immediate, local ones. That is the only property they have in common, and it is the whole justification.

Related articles

Resources