PYTHON / ERRORS AND EXCEPTIONS
assert and defensive programming
Use assert for internal invariants and real exceptions for input validation, and know why -O makes assert unsafe for checks that must always run.
What you will learn
- State post-conditions and invariants with assert plus a message
- Validate caller input with ValueError/TypeError, never with assert
- Recognise that python -O deletes every assert and sets __debug__ to False
- Avoid assert (cond, "msg"): a non-empty tuple is always truthy
Understanding assert and defensive programming
An assert statement evaluates one expression and, if it is falsy, raises AssertionError with the optional second operand as the message. The important part is what assert means, not what it does: it declares a condition you believe is impossible to violate given correct code around it. That is why a failing assert points at a bug in your own logic, whereas a failing ValueError usually points at bad data arriving from outside.
The reason this distinction has teeth is that assert is not an ordinary statement. When Python runs with the -O flag, the compiler drops assert statements entirely and sets the built-in __debug__ to False, so any check written as an assert simply stops existing. A missing-key check or a permission check written with assert therefore protects you in development and does nothing in an optimized run, which is worse than having no check at all because the code reads as if it is guarded.
Defensive programming means failing fast at the boundary where wrong data enters, with an exception type that describes the problem: TypeError for the wrong kind of object, ValueError for the right kind with an unusable value, KeyError or LookupError for missing entries. Inside the function, once the arguments are known good, assert is the cheap way to pin down the assumptions you rely on, such as a result that must sum to one or a branch that must be unreachable. Checks the program's correctness depends on go in if/raise; checks that document your reasoning go in assert.
def normalize(weights):
total = sum(weights)
scaled = [w / total for w in weights]
# Internal invariant: whatever the input, the result must sum to 1.
assert abs(sum(scaled) - 1.0) < 1e-9, f"normalization broke: {sum(scaled)}"
return scaled
def average(values):
# Caller-facing contract: enforced with a real exception, not an assert.
if not values:
raise ValueError("average() needs at least one value")
return sum(values) / len(values)
print(normalize([2, 3, 5]))
print("__debug__ is", __debug__)
try:
average([])
except ValueError as e:
print("ValueError:", e)
try:
assert 1 == 2, "1 should equal 2"
except AssertionError as e:
print("AssertionError:", e)
assert documents assumptions that should never fail and can be compiled away, so anything a running program must actually check has to be an explicit raise.
Worked examples
The parenthesised assert that never fires
Shows why assert (condition, message) always passes: the tuple itself is the condition.
condition = 1 == 2
message = "values differ"
pair = (condition, message)
print("condition:", condition)
print("bool of the pair:", bool(pair))
try:
assert pair # same shape as assert (condition, message)
print("assert passed even though condition is False")
except AssertionError:
print("this line is never reached")
try:
assert condition, message # correct form: two operands, no parentheses
except AssertionError as e:
print("AssertionError:", e)
Example explained
Line 1bool(pair) is True because any non-empty tuple is truthy, regardless of its contents.
Line 2assert pair therefore tests the container, not the False inside it, so it never raises.
Line 3assert condition, message passes the message as a separate operand and raises correctly.
Line 4Writing assert (condition, message) literally in source also makes CPython emit a SyntaxWarning about the parentheses.
What -O does to your asserts
Runs the same snippet twice, once normally and once optimized, to show the assert disappearing.
import subprocess
import sys
program = (
"def get(d, k):\n"
" assert k in d, 'missing key'\n"
" return d.get(k)\n"
"print(get({}, 'a'))\n"
)
for flags, label in (([], "normal"), (["-O"], "with -O")):
done = subprocess.run([sys.executable, *flags, "-c", program],
capture_output=True, text=True)
if done.stdout.strip():
print(label, "->", done.stdout.strip())
else:
print(label, "->", done.stderr.strip().splitlines()[-1])
Example explained
Line 1In the normal run the assert fails, so nothing is printed and the traceback ends in AssertionError.
Line 2With -O the compiler emits no code for the assert, so get() falls through to d.get(k) and returns None.
Line 3The bug is now silent: a missing key looks like a stored None instead of an error.
Line 4This is the concrete reason a check that must always run cannot be an assert.
Validating at the boundary
Rejects bad arguments with the exception type that names the actual problem.
def set_volume(level):
if not isinstance(level, int):
raise TypeError(f"level must be int, got {type(level).__name__}")
if not 0 <= level <= 100:
raise ValueError(f"level out of range: {level}")
return f"volume={level}"
print(set_volume(30))
for bad in ["30", 150]:
try:
set_volume(bad)
except (TypeError, ValueError) as e:
print(type(e).__name__, "-", e)
Example explained
Line 1The type check comes first, because 0 <= "30" <= 100 would itself raise a confusing TypeError.
Line 2TypeError signals the wrong kind of object; ValueError signals a correct type with an unusable value.
Line 3Both checks survive -O, and callers can catch them selectively by type.
Line 4Once past these guards, the rest of the function may assume level is an int in range.
Important notes
AssertionError is a normal exception and can be caught, but catching it in application code usually hides a real bug; let it propagate.
isinstance(True, int) is True, so a bool slips through an int guard; add a separate check if that matters.
Common mistakes
Using assert to validate user input or request data: the check vanishes under -O and the bad value flows straight into the program.
Writing assert (x > 0, "x must be positive"): the tuple is always truthy, so the assertion can never fail and the bug is never caught.
Putting side effects such as assert queue.pop() or assert log(x) inside an assert: the work stops happening in optimized runs.
Try it yourself
Change, predict, then run
Write parse_percent(text) that raises TypeError if text is not a str and ValueError if the number is outside 0 to 100, then assert as a post-condition that the returned float is within that range and print the results for "42", "250" and 42.
Open the Python workspaceCheck your understanding
A request handler's only authorization check is assert user_id in session, "not logged in". Why is this dangerous?
- Deploying with python -O removes the assert, so the check silently stops running
- AssertionError cannot be caught with except, so the whole process always dies
- assert accepts only comparison operators, so the membership test is ignored
- The assert is evaluated once when the module loads and cached for later requests
Show answer
Optimized mode compiles assert statements away and sets __debug__ to False, so in production the handler would treat every request as authorized. Option 2 is tempting because AssertionError does end a request when unhandled, but it is an ordinary exception subclass of Exception and perfectly catchable; the risk is the check disappearing, not being uncatchable.