Introduction
Programmers are optimists. We write code assuming the caller will pass exactly what we expect, that inputs will always look reasonable, and that the happy path is the only path. Then production happens, weird inputs arrive, and the code blows up in ways nobody predicted.
This guide is a focused tour of defensive coding — the handful of habits that make functions robust without making them unreadable. Empty inputs, wrong types, boundary values, and the small validation gates that catch issues at the door instead of in a stack trace at 3am.
📚 Table of contents
- The optimistic version — what most people write first
- The failures — ZeroDivisionError, TypeError, and silent bugs
- The robust version — validation at the door
- Dynamic typing means type hints are documentation, not guarantees
- Where to validate and where not to
- Patterns: filter-and-coerce vs raise-and-stop
- Common mistakes and best practices
- Frequently asked questions
😇 The optimistic version
Take the most innocent-looking function in any codebase — calculating an average:
def calculate_average(numbers):
total = 0
for n in numbers:
total += n
return total / len(numbers)
print(calculate_average([10, 20, 30])) # 20.0
Looks fine. Reads fine. Passes the obvious test. Ships.
💥 The failures
Now try the inputs the optimist didn’t imagine:
calculate_average([])
# ZeroDivisionError: division by zero
calculate_average([1, 2, "hello"])
# TypeError: unsupported operand type(s) for +=: 'int' and 'str'
calculate_average(None)
# TypeError: 'NoneType' object is not iterable
These look silly until you remember a real production function runs hundreds of thousands of times, fed by upstream systems you don’t fully control: APIs, message queues, user submissions, third-party data exports. Sooner or later, every weird input does happen.
🛡️ The robust version
from typing import Iterable, Union
Number = Union[int, float]
def calculate_average(numbers: Iterable[Number]) -> float:
if not isinstance(numbers, (list, tuple)):
raise TypeError(f"numbers must be a list or tuple, got {type(numbers).__name__}")
# keep only int/float values, drop garbage
cleaned = [n for n in numbers if isinstance(n, (int, float))]
if not cleaned:
raise ValueError("numbers must contain at least one numeric value")
return sum(cleaned) / len(cleaned)
Slightly longer, dramatically more robust. The function now handles:
- Caller passes
None→ clearTypeErrorinstead of a confusing iteration failure. - Caller passes a string by accident → same.
- Caller passes a list of mixed garbage → non-numeric values dropped instead of crashing on
+=. - Empty list → clear
ValueErrorwith a useful message.
The line cost is small. The debugging cost it saves is enormous.
⚠️ Type hints don’t enforce types
Python is dynamically typed. A type hint like list[int] is documentation for humans and a
target for static analysers (mypy, pyright) — not a runtime guarantee. At runtime, callers can
pass anything, and Python won’t object until you try to use it the wrong way.
So either:
- Use mypy strictly in CI so wrong types fail before merge.
- Validate at runtime for any function exposed across module / service boundaries.
Inside one tightly-coupled module, you can probably skip the runtime check. Across a public API surface, validate.
📍 Where to validate (and where not to)
- Validate at system boundaries: HTTP request handlers, queue consumers, file readers, anywhere data crosses from “outside” to “inside.”
- Validate at public API surfaces: functions other modules import.
- Don’t validate inside helper functions that are only called by code you control — you’d just be checking the same thing twice.
- Use Pydantic / dataclasses for shape validation when you have anything beyond a couple of fields. Hand-rolled
isinstancecalls don’t scale.
🎛️ Filter-and-coerce vs raise-and-stop
Two reasonable strategies, picked deliberately per function:
🚦 Raise and stop
If a bad input means the request is fundamentally broken, raise early with a clear message. Lets the caller catch and handle once, instead of debugging downstream symptoms.
🧹 Filter and coerce
If a bad input means “some of the data is suspect but the operation should still complete,” drop the bad parts and proceed. Log what you dropped so it doesn’t disappear silently.
Don’t default to one. Pick per use-case. A payment-processing function should raise on any weirdness; a daily analytics aggregator can skip bad rows and log them.
✨ Best practices & common mistakes
✅ Do
- Validate at system boundaries by default.
- Write clear error messages that include the bad value and expected type.
- Test empty inputs, single-element inputs, and wrong-type inputs explicitly.
- Reach for Pydantic / dataclasses on anything more than trivial.
❌ Don’t
- Catch
Exceptionand silently returnNone— debugging hell. - Rely on type hints alone for runtime safety.
- Defensive-code internal helpers the caller already validated.
- Throw away the bad input data without logging it — you can’t fix what you can’t see.
Explore More on DevShelf
-
Learn Matplotlib in 30 Minutes
Data you've validated defensively is data you can visualize confidently — a natural next step in the Python stack.
-
Go Programming: Zero to Concurrency
How another language solves the same edge-case problem — Go's explicit error returns vs. Python's exception model.