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
dictwhere alist[float]was expected produces a plausible answer on the first call and aTypeErroron 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 theZeroDivisionErrorthat was always there. Logging beatsDEBUGline 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.20constraint 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.
9. Summary + related articles
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
- Python Fundamentals — the silent-wrong-answer bugs these habits are defending against
- Python Data Structures — the container choice a type hint makes explicit at the boundary
- Python Advanced — decorators for timing and retry, and dataclasses as typed structure
- Async Programming — where logging is the only practical way to see what happened
- LLM Observability — what to log when the thing you are logging is a model call
- Model Evaluation — testing, for code whose output is not deterministic
Resources
typingmodule documentation and the mypy cheat sheet — the fastest reference for annotation syntax: https://mypy.readthedocs.io/en/stable/cheat_sheet_py3.html- PEP 484 (type hints) and PEP 526 (variable annotations) — the design rationale, including why they are not enforced: https://peps.python.org/pep-0484/
- pytest documentation, especially "Parametrizing tests" and
approxfor float comparison: https://docs.pytest.org/en/stable/how-to/parametrize.html - Python
loggingHOWTO and the Logging Cookbook — the cookbook is the more useful of the two: https://docs.python.org/3/howto/logging-cookbook.html pip-tools, Poetry, anduv— three approaches to lockfiles;uvis the fastest and increasingly the default: https://docs.astral.sh/uv/- Ruff — linter and formatter in one, fast enough to run on save, and a strict superset of most of what flake8 and isort checked: https://docs.astral.sh/ruff/
- Boehm, B. & Basili, V. (2001). Software Defect Reduction Top 10 List. IEEE Computer 34(1) — the source of the cost-by-stage argument in §4
- Percival, H. & Gregory, B. — Architecture Patterns with Python — testing and dependency structure for services rather than scripts