Defensive Programming in Python
A 75-90 minute Python tutorial for students who already know functions, classes, exceptions, and basic pytest. You will practice rejecting bad input clearly, preserving valid state, and keeping failure information visible instead of quietly hiding caller bugs.
Behavior From Examples
Why this matters
Defensive programming starts before the first if: you decide which inputs belong to the function’s contract and which ones are caller mistakes. Good examples and non-examples keep validation focused instead of turning it into guesswork.
🎯 You will learn to
- Analyze valid and invalid examples as partitions of the input space.
- Convert examples into a small contract before writing code.
- Distinguish wrong type, malformed value, out-of-range value, and valid edge case.
Predict the contract
Study this intended function:
def parse_percentage(text: str) -> float:
...
It should accept these strings:
"0%"returns0.0"25%"returns25.0"100%"returns100.0" 7% "returns7.0
It should reject these values:
"", because there is no percentage text"25", because the percent sign is missing"-1%", because the value is below the valid range"101%", because the value is above the valid range"abc%", because the numeric part is not a number25, because the value has the wrong type
Before you continue, write one sentence that describes the contract. A strong version says something like: “The caller must pass a string that, after surrounding whitespace is ignored, is a numeric percentage with a percent sign and a value from 0 through 100.”
Why no code yet?
This first step is a classification checkpoint. If you cannot sort an input into the right failure category, the implementation usually becomes either too permissive ("25" becomes accepted) or too vague (ValueError for everything, including wrong types).
def parse_percentage(text: str) -> float:
"""Return the numeric value in a percentage string.
Contract draft:
- caller passes a string
- surrounding whitespace is ignored
- the cleaned value ends with "%"
- the number before "%" is in the inclusive range 0..100
"""
raise NotImplementedError("You will implement validation in later steps.")
Step 1 — Knowledge Check
Min. score: 80%
1. parse_percentage(25) is invalid for which reason?
The caller violated the type part of the contract. Defensive code should report that as a type problem, not try to reinterpret the integer.
2. parse_percentage("25") is invalid for which reason?
The value has the right type but the wrong shape. A parser that accepts both "25" and "25%" is no longer enforcing the stated contract.
3. parse_percentage("101%") is invalid for which reason?
This is a range violation: the input has the right type and shape, but the parsed numeric value is outside the contract.
4. Which input is a valid edge case for this contract?
"0%" sits exactly on the lower boundary and is included by the contract. Boundary values are worth testing precisely because one wrong comparison can reject them.
5. Four programmers each propose a different implementation strategy for parse_percentage. Which strategy is the most defensive — and why?
Defensive programming is about making the failure tell the caller what to fix. Distinct exception classes do that — they make the kind of mistake (wrong type vs wrong shape vs wrong value) visible at the point of failure. The other three strategies all silently broaden, swallow, or flatten the failure signal.
Precise Exceptions
Why this matters
Defensive code is more useful when the failure tells the caller what kind of mistake they made. Python already has a useful split: TypeError for the wrong kind of value, ValueError for the right kind of value with an unacceptable value.
🎯 You will learn to
- Apply
TypeErrorandValueErrorto different validation failures. - Use
pytest.raisesto verify the exact exception type. - Reject
boolwhere an integer count is required.
Your task
Open repeat_text.py. Implement:
def repeat(text: str, times: int) -> str:
...
Contract:
textmust be astr; otherwise raiseTypeError.timesmust be anint, but notbool; otherwise raiseTypeError.timesmust be non-negative; otherwise raiseValueError.- Valid inputs return
text * times.
The tests check exact exception classes. A subclass or a broad Exception is not enough because the exception choice is the learning target here.
def repeat(text: str, times: int) -> str:
"""Return text repeated times times."""
return text * times
import pytest
from repeat_text import repeat
def test_repeats_text_for_valid_count():
assert repeat("ha", 3) == "hahaha"
assert repeat("ha", 0) == ""
def test_wrong_text_type_is_type_error():
with pytest.raises(TypeError) as excinfo:
repeat(123, 2)
assert excinfo.type is TypeError
def test_wrong_times_type_is_type_error():
with pytest.raises(TypeError) as excinfo:
repeat("ha", "3")
assert excinfo.type is TypeError
def test_negative_times_is_value_error():
with pytest.raises(ValueError) as excinfo:
repeat("ha", -1)
assert excinfo.type is ValueError
Solution
def repeat(text: str, times: int) -> str:
"""Return text repeated times times."""
if not isinstance(text, str):
raise TypeError("text must be a string")
if isinstance(times, bool) or not isinstance(times, int):
raise TypeError("times must be an integer")
if times < 0:
raise ValueError("times must be non-negative")
return text * times
The implementation checks the type part of the contract first, then the value constraint. That keeps the exception type aligned with the caller’s mistake.
Step 2 — Knowledge Check
Min. score: 80%
1. repeat("ha", "3") raises TypeError rather than ValueError. Why is TypeError the right choice here?
TypeError signals ‘wrong kind of object’; ValueError signals ‘right kind, unacceptable value’. The two carry distinct information for the caller — and choosing the right one is part of the defensive contract.
2. repeat("ha", True) raises TypeError even though True is an int in Python. What is this defending against?
Python’s bool is a subclass of int, so isinstance(True, int) is True. The defensive check isinstance(x, bool) or not isinstance(x, int) catches True/False before accepting them as counts — because passing a boolean to a function that wants a count is almost certainly a caller mistake.
3. A reviewer suggests collapsing all three checks in repeat into one line: if not (isinstance(text, str) and isinstance(times, int) and times >= 0): raise ValueError("invalid input"). What does this change cost you?
Defensive programming is about information at the failure site. Collapsing checks erases the distinction between ‘wrong kind’ and ‘wrong value’ — the very signal the caller needs to fix their code.
Rejecting Quiet Repairs
Why this matters
Defensive programming is not the same as making every bad call work somehow. Quietly repairing a caller bug can move the failure far away from the cause, which makes the next bug harder to debug.
🎯 You will learn to
- Evaluate when an automatic repair hides a caller mistake.
- Reject invalid relationships between arguments.
- Preserve the contract of a small numeric helper.
Your task
clamp(value, lower, upper) should return value limited to the inclusive range [lower, upper].
Valid examples:
clamp(5, 0, 10) # 5
clamp(-2, 0, 10) # 0
clamp(12, 0, 10) # 10
The starter code tries to be helpful by swapping the bounds when lower > upper. Delete that behavior. The contract should reject reversed bounds with ValueError; callers need to know they passed the arguments in the wrong order.
def clamp(value: float, lower: float, upper: float) -> float:
"""Return value limited to the inclusive interval [lower, upper]."""
if lower > upper:
# This hides the caller's bug. Replace it with a clear failure.
lower, upper = upper, lower
if value < lower:
return lower
if value > upper:
return upper
return value
import pytest
from ranges import clamp
def test_value_inside_range_is_unchanged():
assert clamp(5, 0, 10) == 5
def test_value_below_range_returns_lower_bound():
assert clamp(-2, 0, 10) == 0
def test_value_above_range_returns_upper_bound():
assert clamp(12, 0, 10) == 10
def test_reversed_bounds_are_rejected():
with pytest.raises(ValueError) as excinfo:
clamp(5, 10, 0)
assert excinfo.type is ValueError
Solution
def clamp(value: float, lower: float, upper: float) -> float:
"""Return value limited to the inclusive interval [lower, upper]."""
if lower > upper:
raise ValueError("lower must be less than or equal to upper")
if value < lower:
return lower
if value > upper:
return upper
return value
The function still helps valid callers by clamping values, but it refuses to reinterpret a reversed range. That keeps the failure close to the caller’s mistake.
Step 3 — Knowledge Check
Min. score: 80%
1. Why is silently swapping reversed bounds in clamp(7, 9, 3) worse than raising ValueError?
Defensive programming is partly about preserving the visibility of bugs. Silent repair shifts a bug from ‘detected immediately’ to ‘detected weeks later in production’, which is the worst possible move.
2. Postel’s Law (“be liberal in what you accept, conservative in what you send”) is a famous internet design principle. A web framework’s URL router silently strips trailing slashes from URLs before matching. Is this the same anti-pattern as clamp’s silent swap?
Defensive programming has a location dimension: at the system boundary (HTTP input, file parsing, command-line args), liberal acceptance with documented normalization is often correct. Inside trusted code, silent repair hides bugs and should be rejected — exactly what clamp enforces.
3. A teammate proposes that clamp(value, lower, upper) also coerce non-numeric inputs: clamp(\"5\", 0, 10) would try float(\"5\") and continue. Is this a good defensive addition?
The pattern in this step generalizes: any time a function silently expands the set of inputs it accepts, it’s hiding caller bugs. The right move is to reject early with a precise exception, not to coerce.
State Integrity
Why this matters
Defensive programming is not only about function inputs. Objects also need valid state, and the easiest time to protect that state is at construction.
🎯 You will learn to
- Enforce a class invariant in
__post_init__. - Validate construction-time state before methods rely on it.
- Reject method inputs that do not belong to the public contract.
Your task
Open intervals.py. The class represents a closed interval, meaning both endpoints are included. Its invariant is:
start <= end
Implement two pieces:
__post_init__rejects invalid construction withValueError.contains(value)returns whether an integer value is inside the interval, and raisesTypeErrorfor non-integers orbool.
The class is frozen=True, so valid instances cannot be edited after construction. That makes the construction check especially important.
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class ClosedInterval:
start: int
end: int
def __post_init__(self) -> None:
# Enforce the invariant here.
pass
def contains(self, value: int) -> bool:
# Return True when value is between start and end inclusive.
return False
import pytest
from intervals import ClosedInterval
def test_valid_interval_contains_endpoints_and_middle():
interval = ClosedInterval(3, 7)
assert interval.contains(3) is True
assert interval.contains(5) is True
assert interval.contains(7) is True
def test_valid_interval_rejects_outside_values():
interval = ClosedInterval(3, 7)
assert interval.contains(2) is False
assert interval.contains(8) is False
def test_reversed_interval_cannot_be_constructed():
with pytest.raises(ValueError) as excinfo:
ClosedInterval(7, 3)
assert excinfo.type is ValueError
def test_contains_rejects_non_integer_values():
interval = ClosedInterval(3, 7)
with pytest.raises(TypeError) as excinfo:
interval.contains("5")
assert excinfo.type is TypeError
Solution
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class ClosedInterval:
start: int
end: int
def __post_init__(self) -> None:
if not isinstance(self.start, int) or isinstance(self.start, bool):
raise TypeError("start must be an integer")
if not isinstance(self.end, int) or isinstance(self.end, bool):
raise TypeError("end must be an integer")
if self.start > self.end:
raise ValueError("start must be less than or equal to end")
def contains(self, value: int) -> bool:
if not isinstance(value, int) or isinstance(value, bool):
raise TypeError("value must be an integer")
return self.start <= value <= self.end
The object rejects invalid state once, at construction, and each method can rely on the invariant after that. Method-specific validation is still needed for new caller input.
Step 4 — Knowledge Check
Min. score: 80%
1. Why does ClosedInterval validate start <= end in __post_init__ rather than inside contains?
Construction is the one stable time when invalid state is possible. After __post_init__, the invariant is established — and because the class is frozen=True, it can’t be broken later. Every method can rely on start <= end without re-checking.
2. The contains(value) method still validates that value is a non-bool integer, even though the interval itself is already a valid object. Why?
Invariants protect object state. Preconditions protect method arguments. They’re two distinct concerns: the invariant is about what self looks like; the precondition is about what the caller just handed you.
3. ClosedInterval(True, 5) raises TypeError even though True is technically an int. This is the same trap as Step 2’s repeat(\"ha\", True). What is being defended against in both cases?
Both repeat and ClosedInterval defend the same way: explicitly check isinstance(x, bool) before accepting isinstance(x, int). The pattern is worth memorizing — almost every Python function that wants ‘an integer (and not a bool)’ uses it.
Visible Error Causes
Why this matters
except Exception: return {} looks friendly until production starts using the empty default after a missing file or broken JSON document. Defensive code should translate low-level failures into domain language while preserving the original cause for debugging.
🎯 You will learn to
- Replace broad exception swallowing with targeted exception handling.
- Raise a domain-specific exception with
raise ... from exc. - Preserve useful failure information without leaking low-level details into callers.
Your task
Open config_loader.py. Replace the broad fallback with a clear contract:
load_json_config(path)returns a JSON object as adict.- Missing or unreadable files raise
ConfigError(...) from exc. - Invalid JSON raises
ConfigError(...) from exc. - Valid JSON that is not an object raises
ConfigErrorwithout pretending it is a usable config.
Do not catch Exception or BaseException. Catch the failures you can translate.
🔍 How the hidden check works
A pytest assertion that simply looks at behavior could miss a sneaky except Exception: that happens to translate to the right exception in the test cases. To guard against that, the hidden check parses your code’s Abstract Syntax Tree (AST) and explicitly rejects any except handler whose type is Exception or BaseException. This is the same technique used in real-world code review tools (Bandit, Ruff’s BLE001) — and it’s worth knowing as a defensive-programming technique in its own right: when you can’t trust behavior alone, parse the structure.
import json
from pathlib import Path
from typing import Any
class ConfigError(RuntimeError):
"""Raised when an application config file cannot be loaded."""
def load_json_config(path: Path) -> dict[str, Any]:
"""Load a JSON config object from path."""
try:
with Path(path).open(encoding="utf-8") as config_file:
return json.load(config_file)
except Exception:
return {}
import json
import pytest
from pathlib import Path
from config_loader import ConfigError, load_json_config
def test_valid_config_file_returns_dict():
path = Path("/tutorial/app-config.json")
path.write_text(json.dumps({"debug": True, "port": 8080}), encoding="utf-8")
assert load_json_config(path) == {"debug": True, "port": 8080}
def test_missing_file_raises_config_error_with_cause():
path = Path("/tutorial/does-not-exist.json")
with pytest.raises(ConfigError) as excinfo:
load_json_config(path)
assert isinstance(excinfo.value.__cause__, OSError)
def test_invalid_json_raises_config_error_with_cause():
path = Path("/tutorial/broken-config.json")
path.write_text("{not valid json", encoding="utf-8")
with pytest.raises(ConfigError) as excinfo:
load_json_config(path)
assert isinstance(excinfo.value.__cause__, json.JSONDecodeError)
Solution
import json
from pathlib import Path
from typing import Any
class ConfigError(RuntimeError):
"""Raised when an application config file cannot be loaded."""
def load_json_config(path: Path) -> dict[str, Any]:
"""Load a JSON config object from path."""
config_path = Path(path)
try:
text = config_path.read_text(encoding="utf-8")
except OSError as exc:
raise ConfigError(f"could not read config file: {config_path}") from exc
try:
data = json.loads(text)
except json.JSONDecodeError as exc:
raise ConfigError(f"invalid JSON config file: {config_path}") from exc
if not isinstance(data, dict):
raise ConfigError("config file must contain a JSON object")
return data
The loader now catches only the failures it can translate. raise ... from exc keeps the low-level exception in __cause__, so debugging tools still show the real source of the failure.
Step 5 — Knowledge Check
Min. score: 80%
1. The original buggy load_json_config did except Exception: return {}. What is the worst long-term harm of this pattern in production?
Broad-except + sentinel return is the canonical ‘silent corruption’ anti-pattern. Failures that should stop the program become failures that change its behavior in subtle ways — far harder to debug than a clean crash.
2. What does raise ConfigError(...) from exc give callers that raise ConfigError(...) alone does not?
raise X from Y sets X.__cause__ = Y, which Python prints as ‘The above exception was the direct cause of the following exception…’ in tracebacks. The caller catches ConfigError (the right abstraction), but a debugger can still see the underlying file I/O or JSON parse failure that actually caused it.
3. The solution checks if not isinstance(data, dict): raise ConfigError(...). Why is this validation step necessary even after json.load succeeded?
JSON has six top-level value types; only one is a dict (a JSON ‘object’). [1, 2, 3] is valid JSON. So is "hello". json.load succeeds for all of them, then hands you something that’s not the dict your function promised. That gap between ‘parses cleanly’ and ‘matches the contract’ is exactly where shape validation lives.
Port Range Parser
Why this matters
Real defensive programming combines the pieces: classify input, reject invalid forms, choose precise exceptions, and guarantee that valid results obey a postcondition. A small parser is enough to practice the full loop without adding domain clutter.
🎯 You will learn to
- Create a parser that rejects wrong types, malformed strings, out-of-range values, and reversed ranges.
- Apply postcondition reasoning to returned tuples.
- Test boundary values around a finite numeric domain.
Your task
Implement:
def parse_port_range(spec: str) -> tuple[int, int]:
...
Accepted forms:
"8080"returns(8080, 8080)."8000-8080"returns(8000, 8080).- Surrounding whitespace around the whole spec is okay.
Rejected forms:
- wrong type: raise
TypeError - malformed string: raise
ValueError - port outside
0..65535: raiseValueError - reversed range such as
"9000-8000": raiseValueError
Postcondition for every successful call: the result is a pair of integers where 0 <= start <= end <= 65535.
🎓 After this capstone — should you always program defensively?
Across this tutorial you’ve built a complete defensive vocabulary: classify inputs into partitions, raise the right exception class, reject silent repair, protect object invariants, translate boundary failures into domain errors, and combine all of it into a parser whose contract is verifiable from the outside.
That’s the right toolkit at a system boundary — anywhere your code receives data from a user, a network call, a file, a database, or another team’s API. The further you go from that boundary, between trusted modules you wrote yourself, the more the same checks start to feel like noise that compounds across a large codebase.
Bertrand Meyer (who coined the term in the 1980s) argued against applying defensive programming uniformly. His alternative — Design by Contract — asks a sharper question: instead of “how do I reject bad input?”, whose job was this? That single shift changes what code looks like in the interior of a system.
Next: the Design by Contract in Python tutorial picks up where this one ends. It uses the same Python and the same techniques you just practiced, but reframes them around responsibility allocation — and gives you a criterion for deciding where defensive programming earns its keep and where it costs more than it gives.
def parse_port_range(spec: str) -> tuple[int, int]:
"""Parse a single port or inclusive port range."""
raise NotImplementedError("parse the port range")
import pytest
from ports import parse_port_range
def assert_valid_port_range(result):
assert isinstance(result, tuple)
assert len(result) == 2
start, end = result
assert isinstance(start, int)
assert isinstance(end, int)
assert 0 <= start <= end <= 65535
def test_single_port_becomes_one_point_range():
result = parse_port_range("8080")
assert result == (8080, 8080)
assert_valid_port_range(result)
def test_range_preserves_start_and_end():
result = parse_port_range("8000-8080")
assert result == (8000, 8080)
assert_valid_port_range(result)
def test_boundary_ports_are_valid():
assert parse_port_range("0") == (0, 0)
assert parse_port_range("65535") == (65535, 65535)
assert parse_port_range("0-65535") == (0, 65535)
def test_wrong_type_is_type_error():
with pytest.raises(TypeError) as excinfo:
parse_port_range(8080)
assert excinfo.type is TypeError
def test_malformed_specs_are_value_error():
for spec in ["", "abc", "80/http", "80-", "-90", "80-90-100"]:
with pytest.raises(ValueError):
parse_port_range(spec)
def test_out_of_range_ports_are_value_error():
for spec in ["-1", "65536", "80-70000"]:
with pytest.raises(ValueError):
parse_port_range(spec)
def test_reversed_range_is_value_error():
with pytest.raises(ValueError):
parse_port_range("9000-8000")
Solution
def parse_port_range(spec: str) -> tuple[int, int]:
"""Parse a single port or inclusive port range."""
if not isinstance(spec, str):
raise TypeError("spec must be a string")
cleaned = spec.strip()
if not cleaned:
raise ValueError("port range cannot be empty")
if "-" in cleaned:
parts = cleaned.split("-")
if len(parts) != 2 or not parts[0] or not parts[1]:
raise ValueError("range must be start-end")
if any(part.strip() != part for part in parts):
raise ValueError("range cannot contain embedded whitespace")
start_text, end_text = parts
else:
start_text = end_text = cleaned
if not start_text.isdecimal() or not end_text.isdecimal():
raise ValueError("ports must be decimal integers")
start = int(start_text)
end = int(end_text)
if not (0 <= start <= 65535 and 0 <= end <= 65535):
raise ValueError("ports must be in the range 0..65535")
if start > end:
raise ValueError("start port must be less than or equal to end port")
return (start, end)
The parser checks type first, then string shape, then numeric range, then the relationship between endpoints. The return happens only after the postcondition is true.
Step 6 — Knowledge Check
Min. score: 80%
1. A student implementation turns "9000-8000" into (8000, 9000) so the returned tuple is ordered. What is the main problem?
Reversed ranges are caller mistakes. Silently swapping them repeats the quiet-repair problem from clamp.
2. Which assertion best captures the postcondition of a successful parse_port_range call?
The postcondition includes tuple shape, integer endpoints, range limits, and ordering with equality allowed.
3. Which failure should be a TypeError rather than a ValueError?
TypeError is for the wrong kind of argument. The malformed, out-of-range, and reversed examples are all strings, so they are ValueError cases.
4. Retrieval from Step 3: a teammate proposes that parse_port_range(\"9000-8000\") should silently swap to (8000, 9000) because "the user obviously meant the lower port first." What principle from clamp (Step 3) applies here?
This is the same pattern as clamp’s reversed-bounds swap. The harm is preserving the visibility of the bug — silent repair shifts caller errors from ‘detected immediately’ to ‘detected weeks later in production.’
5. Retrieval from Step 5: imagine parse_port_range was extended to read its input from a config file — port_range = json.load(...)\\[\"ports\"\\]. Which defensive pattern from load_json_config (Step 5) should re-enter the picture?
Defensive programming has layers: boundary validation (Step 5’s shape check), targeted exception translation (Step 5’s from exc), and interior contract enforcement (Step 6’s port parser). Each layer covers what the others can’t. Skipping any one of them lets a class of bug through.