Handling Exceptions - try, except, finally, raise
Context
A failure can be caught instead of ending the program. The risky work goes in one block, the response in another, cleanup that must happen either way in a third, and a failure can also be raised deliberately when your own code detects something it cannot accept.
Real data forces this. A cleaning pass over a large file will meet rows that cannot be converted, and the choice is between dying on the first one and recording it, skipping it, and carrying on to report how many were bad at the end. Deliberately raising is the other half, a loading function that finds a required column missing should stop loudly at that point rather than return half a dataset that looks fine.
Catching too broadly is the damage. A response that swallows every possible failure will also swallow the typo in your own code, so a pipeline reports every row as bad because it caught its own bug and counted it as a bad row, and the run finishes clean with an empty result. Catching narrowly, and letting every other type through, is the discipline. The previous lesson taught reading a failure, this lesson responds to one.
Goal
- Wrap risky code in a
tryblock so an anticipated failure is caught instead of crashing. - Catch specific exception types and let other types propagate, avoiding over-broad handling.
- Run cleanup with
finallyso it executes whether or not an exception occurred. - Raise an exception deliberately to signal a condition your code refuses to accept.
- Trace how an uncaught exception propagates up the call stack until something handles it or the program crashes.
Prerequisites
Optional, both of them. The lesson does not wait for either, they are here if you want the whole shape before the details.
- YouTube - "Python Tutorial: Using Try/Except Blocks for Error Handling" by Corey Schafer. Watch the whole video, one worked example of specific
exceptclauses and the control flow oftry,except,elseandfinally. - Official docs - The Python Tutorial, "Handling Exceptions", "Raising Exceptions" and "Defining Clean-up Actions".
Table of Contents
- try / except
- Catching Specific Exceptions
- finally
- raise
- Exception Propagation
Theory
A loader that skips a malformed row instead of dying on it is the difference between a toy and a tool, and targeted exception handling is how you build that resilience. The previous lesson taught you to read a failure after it crashed the program. This lesson teaches you to intercept that failure while it happens, to decide, deliberately, which errors your code will absorb and which it will let through. The exception types you learned to name now become the targets you catch by name.
try / except
What. A try/except block wraps code that might raise an exception. If the try body raises, control jumps to the matching except block instead of crashing the program. If nothing is raised, the except block is skipped entirely.
Why. Some failures are not bugs, they are expected facts of working with real data, a malformed row, a missing optional file, a field that will not convert. Crashing on these is wrong, the program should respond and continue. try/except is the mechanism that turns a fatal exception into a handled event you control.
When. Use try/except around an operation that can fail for reasons outside your control and that you have a sensible response to: skipping a bad record, supplying a default, retrying, or logging and moving on. Do not use it to paper over bugs in your own logic.
The try block holds the risky code, and the except block holds the response. When the try body raises, execution leaves it immediately and runs the first except whose type matches, then continues after the whole construct, unless that body ends in a return, raise, break or continue. With no matching except, the exception keeps rising, as Catching Specific Exceptions shows.
# Without handling, a bad value crashes the whole program.
# With try/except, the failure becomes a controlled response.
field = "n/a"
try:
value = float(field) # this raises ValueError for "n/a"
print("parsed:", value) # SKIPPED when the line above raises
except ValueError:
value = 0.0 # the response: use a safe default
print("bad value, using default")
print("continuing with", value) # runs either wayThe line that raised is not the only one skipped, everything after it in the try body is skipped too, because the exception leaves the block immediately rather than resuming on the next line.
When the try body does not raise, the except block is skipped entirely. This is the normal, success path.
field = "5.1"
try:
value = float(field) # succeeds, no exception
print("parsed:", value) # this runs
except ValueError:
print("bad value") # SKIPPED, nothing was raised
# Output: parsed: 5.1The except clause is insurance, costing nothing when no exception occurs, which is why wrapping the common-case path in a try is cheap rather than something to ration.
This is the resilient-loader pattern in miniature: wrap the per-row parse, and on failure skip the row instead of dying. One bad row no longer ends the whole load.
lines = ["1,0.9", "2,n/a", "3,0.7"]
records = []
for line in lines:
try:
id_str, score_str = line.split(",")
records.append({"id": int(id_str), "score": float(score_str)})
except ValueError:
# Skip the malformed row and keep going.
print("skipping bad row:", line)
continue
print(records) # [{'id': 1, 'score': 0.9}, {'id': 3, 'score': 0.7}]Note where the try sits, inside the loop rather than around it. Wrapping the whole loop would have ended it at the first bad row, which is the crash you were trying to avoid, wearing a handler.
DON'T:
- DON'T wrap huge blocks of code in one
try. Keep thetrybody to the few lines that can actually raise, so theexceptresponds to a known cause, not a mystery. - DON'T use
try/exceptto hide a bug in your own code. Ifrecords[i]raisesIndexErrorbecause of a counting mistake, fix the logic, do not silence it.
Catching Specific Exceptions
What. Catching specific exceptions means naming the exception type in the except clause, except ValueError:, so the handler fires only for that type and the types that descend from it. You can have several except clauses, each targeting a different type, and one handler can target a tuple of types.
Why. Different failures need different responses, and an over-broad catch hides bugs. A bare except: swallows everything, including the KeyError from your own typo and the KeyboardInterrupt when a user tries to stop the program. Catching specifically means you handle only the types of failure you anticipated and let every other type surface loudly, so a bug that raises any other type stays visible.
When. Always name the narrowest exception type that covers the failure you expect. Use multiple except clauses when distinct failures call for distinct responses, and a tuple of types when one response fits several related failures.
The type after except filters which exceptions it catches. An exception whose type does not match is not caught by that clause, it propagates as if the clause were not there. The exception type is what drives which clause fires, building directly on naming types from the previous lesson.
data = {"score": "n/a"}
try:
value = float(data["score"]) # could raise KeyError OR ValueError
except KeyError:
print("missing 'score' field") # fires only for a missing key
except ValueError:
print("'score' is not a number") # fires only for a bad value
# For data = {"score": "n/a"} the ValueError clause fires.One try body, two ways to fail, and the type is what selects the response. Only one clause ever runs, the first whose type matches.
When one response suits several exception types, catch them together with a tuple. This avoids duplicating the same handler body.
def to_float(x):
try:
return float(x)
# A tuple catches several related types with one handler.
except (ValueError, TypeError):
return None # non-numeric string OR wrong type both -> None
print(to_float("5.1")) # 5.1
print(to_float("n/a")) # None (ValueError)
print(to_float(None)) # None (TypeError)The parentheses make it a tuple of types, one clause with several targets, which beats two clauses holding identical bodies.
The danger to avoid is the bare except: (or the nearly-as-broad except Exception:), which catches things you never meant to, including your own bugs. Catching specifically keeps errors of every other type visible.
# BAD: bare except hides a typo bug as if it were a data problem.
def bad(line):
try:
return float(line.split(",")[1])
except: # catches EVERYTHING, including bugs
return None # a NameError from a typo would be silenced here
# GOOD: name the type you actually expect.
def good(line):
try:
return float(line.split(",")[1])
except (ValueError, IndexError): # bad number or missing field only
return NoneThe damage of the bare form is that it turns your own bugs into data problems. Misspell a variable inside that try and the NameError is caught and answered with None, so the row looks malformed and the typo is never seen. Naming the types you actually expect is what keeps that NameError loud.
DON'T:
- DON'T use a bare
except:or a blanketexcept Exception:as a habit. Both swallow your own bugs, and the bare form additionally swallowsKeyboardInterruptandSystemExit, whichexcept Exception:leaves alone because those two descend fromBaseException(the root that every exception type descends from) rather thanException. Name the types you expect. - DON'T order
exceptclauses from broad to narrow. A broad clause first would catch everything before the narrow ones get a chance, so list specific types first.
finally
What. A finally block holds code that runs on every way out of the try, whether the try succeeded, raised and was handled, or raised and is propagating uncaught. It is the guaranteed-cleanup clause, and the one thing it cannot survive is the process itself being ended, by the kill of Lesson 0.2.3 or a kill -9.
Why. Some actions must happen regardless of outcome: closing a file, releasing a lock, restoring a setting. Putting them in finally guarantees they run even when an exception is tearing through the block, which is the same guarantee the with statement gave you for files, now available for any cleanup.
When. Use finally for cleanup that must occur on every path out of the block, success or failure. If you find yourself duplicating cleanup in both the success path and every except, that cleanup belongs in finally.
The finally block runs after the try (and any except), on every exit path. Even if the try raises and no except matches, finally still runs before the exception continues upward.
def process(path):
f = open(path, encoding="utf-8")
try:
return int(f.read()) # might raise ValueError
finally:
f.close() # runs whether or not int() raised
print("file closed")The finally block closes the file on every path. If int(f.read()) succeeds, the value is returned and finally runs first to close the file. If it raises ValueError, the exception starts propagating, but finally still runs and closes the file before the exception leaves the function. Either way the file is closed, this is the same guarantee with open(...) provides, expressed manually.
finally runs even when there is a return in the try or except. The cleanup happens before control actually leaves the function, which is what makes it reliable.
def demo():
try:
return "from try" # value computed...
finally:
print("finally runs") # ...but THIS runs before the return completes
print(demo())
# Output:
# finally runs
# from tryEven though the try block has a return, the finally block runs before the function actually returns, the print appears first, then the returned value. The example shows it for return, and break and an exception leave the same way: finally is the last thing to run on the way out, every time.
In practice you usually prefer with for file cleanup, but finally is the general tool when there is no context manager available, restoring state, releasing a resource, or logging completion regardless of outcome.
DON'T:
- DON'T put a
returninsidefinally. It overrides any return or exception from thetry, silently swallowing errors, keepfinallyto cleanup only. - DON'T duplicate cleanup across the success path and every
exceptclause. If it must always run, move it once intofinally.
raise
What. raise signals an error condition on purpose. You use it to stop execution and report that your code has encountered something it cannot or should not accept, optionally re-raising an exception you caught.
Why. Not every error comes from a built-in operation. When your function receives an argument it cannot work with, a negative count, an empty dataset, an unknown option, the right move is often to raise an exception with a clear message, rather than return a wrong answer or a confusing default. raise lets your code enforce its own contracts and fail loudly at the true source of the problem.
When. Raise when your code detects a condition it refuses to proceed with, especially invalid arguments at a function's boundary. Re-raise (a bare raise inside an except) when you want to log or react to an exception but still let it propagate.
raise ExceptionType("message") creates and raises an exception, stopping execution and producing a traceback if uncaught. Choosing a meaningful type and message makes the failure self-explanatory.
def average(values):
if len(values) == 0:
# Refuse the impossible case with a clear, specific error.
raise ValueError("average() requires at least one value")
return sum(values) / len(values)
print(average([2, 4, 6])) # 4.0
print(average([])) # ValueError: average() requires at least one valueRaising at the boundary is the point. Without the guard the caller would get a ZeroDivisionError from inside the arithmetic, whose message, division by zero, names the symptom and mentions neither average nor the empty list.
A bare raise inside an except block re-raises the exception currently being handled, after you have done something with it. This lets you react without swallowing the error.
def load(path):
try:
with open(path, encoding="utf-8") as f:
return int(f.read())
except ValueError:
print(f"warning: {path} did not contain an integer")
raise # re-raise the SAME exception so the caller still sees itThis is the pattern for "note it, but do not hide it". The original traceback survives untouched. raise err would re-raise the same exception too, but it adds its own line to the traceback, above the line that actually failed.
You can also raise a more meaningful exception in place of a low-level one, translating an obscure failure into a clear one at your boundary. The message should state what your code expected.
def get_rate(config):
try:
return float(config["rate"])
except KeyError:
# Translate a missing key into a clearer, domain-specific message.
raise ValueError("config is missing required 'rate' field")The original KeyError is not erased. If nothing catches the ValueError, the crash prints both, the KeyError first, then the line "During handling of the above exception, another exception occurred:", then the ValueError last. Read the bottom one as the failure and the top one as what provoked it.
DON'T:
- DON'T raise a bare
Exceptionwhen a specific type fits.raise ValueError(...)orraise KeyError(...)lets callers catch precisely,raise Exception(...)forces them to catch broadly. - DON'T raise with a vague message like
"error". State what was expected and what was received, so the traceback explains itself.
Exception Propagation
What. Exception propagation is how an uncaught exception travels up the call stack. When a function raises and does not handle the exception, it exits early, after any finally runs, and the exception moves to its caller, then that caller's caller, until some try/except catches it or it reaches the top and crashes the program.
Why. Understanding propagation tells you where to put a handler. An exception raised deep in a helper can be caught far up in a coordinating function, which is often exactly what you want, handle failures at the level that knows how to respond, not necessarily where they occur. It also explains the traceback you read in the previous lesson: each frame is a level the exception propagated through.
When. Reason about propagation whenever you decide where to catch an error. Catch it at the level with enough context to respond sensibly, and let it propagate up from the low-level code that merely detects it.
An unhandled exception causes its function to exit and hands the exception to the caller. If the caller does not handle it either, it keeps rising. The exception is caught by the nearest enclosing try/except that matches its type, anywhere up the chain.
def parse(line):
return float(line) # raises ValueError, no try here
def load(lines):
return [parse(x) for x in lines] # no try here either
def main():
try:
return load(["1.0", "n/a", "3.0"]) # CAUGHT here, several levels up
except ValueError as err:
print("caught at top:", err)
return []
print(main())
# caught at top: could not convert string to float: 'n/a'
# []The central point is that the handler does not have to be where the error occurs, it is wherever the nearest matching try sits up the chain. Note also what was lost: load was building a list and that whole list is gone, because the exception exited the comprehension rather than skipping one item.
If no handler exists anywhere up the chain, the exception reaches the top level and crashes the program, printing the traceback, which is precisely the multi-frame traceback you learned to read, each frame being one level the exception passed through.
def step2():
return {"a": 1}["missing"] # KeyError, unhandled
def step1():
return step2() # unhandled, propagates
def main():
return step1() # unhandled, propagates
main() # no handler anywhere -> program crashes with a 4-frame tracebackWhere you place the handler is a design choice driven by context. Low-level functions usually should not catch, because they lack the context to respond well, they should let the error rise to a coordinator that does.
# Low level: detect and raise, do NOT swallow, no context to respond.
def parse_row(line):
id_str, score_str = line.split(",")
# may raise
return {"id": int(id_str), "score": float(score_str)}
# High level: catch, because here we know the policy is "skip bad rows".
def load(lines):
out = []
for line in lines:
try:
out.append(parse_row(line))
except ValueError:
continue # the policy decision lives at THIS level
return outparse_row does not catch anything, it has no idea whether a bad row should be skipped, defaulted, or fatal, so it simply detects and raises. load is where the policy lives: it knows the answer is "skip bad rows", so it wraps the call and handles ValueError there. Letting low-level code raise and catching at the level that owns the policy is the clean division that propagation makes possible.
DON'T:
- DON'T catch an exception at a low level just because that is where it occurs. Catch it where you have the context to respond, often several frames up.
- DON'T let an exception propagate to the top in production code that should be resilient. Decide on a level to catch it, or the whole program dies on one failure.
Lab Exercises
Lab Exercise 1: Survive One Failure, Then Tell Two Apart
Write safe_float(text) that returns float(text) when it can and None when it cannot, using try/except with the narrowest type that fits. Test it on "3.14", "n/a" and "", printing each result.
Then, given a record dictionary, write a block that reads record["count"] and converts it to an int, with two separate except clauses: one that prints no count field for a missing key, and one that prints count not numeric for a value that will not convert. Run the same block against {"score": "high"}, {"count": "x"} and {"count": "5"}, and print which path each took.
Add a comment naming the one exception type that float("") raises inside safe_float, and say whether it is the same type as the one float("n/a") raises.
Lab Exercise 2: Cleanup That Always Runs, and an Error You Raise Yourself
Create a small file, then write a function that opens it, reads its single line and converts it to an int inside a try, and uses finally to close the file and print cleanup done. Demonstrate both paths, once with a file holding 42 and once with a file holding n/a, catching the failure at the call site so the script survives. Show that cleanup done printed both times.
Then write average(values) that raises ValueError with a message naming the function and the requirement when handed an empty list, and returns the mean otherwise. Call it once with [2, 4, 6] and once with [], catching the second call and printing the message.
Finally, build the three-level chain main calls load calls parse, where only parse can raise and only main has a handler. Print what main returns, and add a comment stating which function raised, which caught, and what happened to the level in between.
Solution 1: Survive One Failure, Then Tell Two Apart
def safe_float(text):
try:
return float(text)
except ValueError: # the narrowest type that covers both bad inputs
return None
print(safe_float("3.14")) # 3.14
print(safe_float("n/a")) # None
print(safe_float("")) # None
# Both bad inputs raise the SAME type, ValueError. An empty string is not a
# special case to Python, it is simply another string that is not a number.for record in ({"score": "high"}, {"count": "x"}, {"count": "5"}):
try:
count = int(record["count"]) # can raise KeyError, then ValueError
except KeyError: # fires only when the key is absent
print("no count field")
continue
except ValueError: # fires only when the value will not convert
print("count not numeric")
continue
print("count is", count)
# no count field
# count not numeric
# count is 5The two clauses are ordered specific-to-specific here, so order does not matter, but note that the KeyError can only come from the subscript and the ValueError only from the conversion. Each clause is tied to one operation in the body, which is what makes a narrow try body worth keeping narrow.
DON'T:
- DON'T reach for a bare
except:because two things can fail, name both types, that is what having two clauses is for. - DON'T widen the
trybody to cover theprint, aKeyErrororValueErrorfrom your own output code would then be reported as a data problem.
Solution 2: Cleanup That Always Runs, and an Error You Raise Yourself
with open("good.txt", "w", encoding="utf-8") as f:
f.write("42\n")
with open("bad.txt", "w", encoding="utf-8") as f:
f.write("n/a\n")
def read_int(path):
f = open(path, encoding="utf-8")
try:
return int(f.read().strip())
finally:
f.close() # runs on the success path AND while an exception is leaving
print("cleanup done")
print(read_int("good.txt")) # cleanup done, then 42
try:
read_int("bad.txt")
except ValueError as err:
print("caught:", err) # cleanup done printed FIRST, then thiscleanup done prints on both runs, and on the failing run it prints before the handler at the call site does. That ordering is the whole guarantee: finally runs as the exception passes through, not after somebody catches it.
def average(values):
if len(values) == 0:
# our own contract
raise ValueError("average() requires at least one value")
return sum(values) / len(values)
print(average([2, 4, 6])) # 4.0
try:
average([])
except ValueError as err:
print("caught:", err) # caught: average() requires at least one valuedef parse(text):
return float(text) # raises, and does NOT catch
def load(items):
return [parse(x) for x in items] # no handler here either
def main():
try:
return load(["1.0", "n/a", "3.0"])
except ValueError as err:
print("caught at top:", err)
return []
print(main())
# caught at top: could not convert string to float: 'n/a'
# []
# 'parse' raised, 'main' caught, and 'load' did neither. It simply exited, and
# the partial list it was building was discarded along with it.DON'T:
- DON'T duplicate cleanup into both the success path and every
except, that is precisely the duplicationfinallyexists to remove. - DON'T put a handler in
parseorloadhere, neither knows what the program wants done about a bad row, andmaindoes.
Theory Assessment
Q1: What the Block Does, and What It Skips
Explain what happens to the rest of a try body when a line inside it raises, and what happens when nothing raises at all. Then explain why, in a loop that parses many rows, the try belongs inside the loop rather than wrapped around it.
Hint: An exception leaves the block rather than resuming after the failing line. For the second half, ask how many rows a handler around the whole loop would let you process after the first bad one.
Q2: Why Name the Type, and What finally Guarantees
Explain what a bare except: catches that except ValueError: does not, and give a concrete way that breadth turns one of your own bugs into an apparent data problem. Then state exactly when a finally block runs, including the case where the exception is not caught anywhere.
Hint: For the first half, imagine a misspelled variable name inside the try. For the second, list the three ways control can leave a try and check whether finally runs on each.
Q3: Raising on Purpose, and Raising Again
Explain when your own code should raise rather than return a default, using an empty-list average as the example, and say what a caller gains from the exception over a returned None. Then explain what a bare raise inside an except block does, and why you would ever catch something only to re-raise it.
Hint: For the first half, ask what a downstream ZeroDivisionError tells the caller compared with a message naming the function. For the second, the answer involves doing something on the way past.
Q4: Five Lines of Output, in an Order You Did Not Expect
Read the following without running it, then state every line of output in order.
def parse(text):
try:
return float(text)
finally:
print("parse done:", text)
def load(items):
results = []
for it in items:
try:
results.append(parse(it))
except ValueError:
print("skipped:", it)
return results
print(load(["1.5", "n/a", "2.5"]))parse has a finally but no except, and load has an except but no finally. Explain what happens on the "n/a" item in particular, naming which of the two printed lines comes first and why.
Hint: parse cannot handle the exception, so the exception leaves it. Ask whether the finally runs before or after the exception arrives at load's handler.
Answer 1:
When a line inside a try raises, the rest of the try body is skipped entirely. Control leaves the block at the point of the failure and jumps to the first matching except clause, so execution does not resume on the line after the one that raised. Any work the body had left to do is simply not done.
When nothing raises, the whole try body runs and every except clause is skipped. The handler costs nothing on the success path, which is why wrapping ordinary code in a try is cheap rather than something to be rationed.
In a loop, the try belongs inside because that is what makes the failure per-row. Wrapped around the whole loop, the first bad row raises, the exception leaves the loop entirely, and every remaining row goes unprocessed, so you have written a handler that produces the same outcome as the crash it replaced. Inside the loop, the handler runs for that one row and the loop continues to the next.
DON'T wrap a whole loop in one try when you meant to skip bad items, the exception exits the loop rather than the iteration.
Answer 2:
A bare except: catches everything, including exceptions that are not data problems at all. except ValueError: catches only that type and the types that descend from it, and lets every other exception continue upward. The bare form additionally catches KeyboardInterrupt and SystemExit, which even except Exception: leaves alone, because those two descend from BaseException rather than from Exception.
The concrete damage: misspell a variable inside the try, say recrods for records, and Python raises NameError. A bare except: catches it, runs the handler that was written for malformed data, and returns a default. Every row now appears to be bad data, the typo never surfaces, and you go looking at the file instead of the code.
finally runs on every path out of the block. It runs when the try succeeds, when the try raises and an except handles it, and when the try raises and nothing handles it, in which case finally runs as the exception passes through on its way up and the exception then continues to propagate.
DON'T treat except Exception: as the safe middle ground by default, it still swallows your own bugs, sparing only KeyboardInterrupt, SystemExit and the few other exceptions outside Exception.
Answer 3:
Your code should raise when it detects a condition it cannot proceed with sensibly. For average([]) the alternative is letting sum([]) / len([]) raise ZeroDivisionError somewhere inside the arithmetic, whose message, division by zero, names the symptom and mentions neither the function nor the empty list. Raising ValueError("average() requires at least one value") at the boundary names both.
What the caller gains over a returned None is that the failure cannot be ignored. A returned None travels onward silently and fails later somewhere unrelated, or worse, gets counted into a report. An exception stops the program at the point of the problem, and a caller who has a sensible response can catch it deliberately.
A bare raise inside an except re-raises the exception currently being handled, preserving its original traceback. You would catch only to re-raise when you want to do something on the way past without changing the outcome: log the failure with context the caller lacks, close something, increment a counter. The error still reaches whoever needed to know about it.
DON'T replace a bare raise with a freshly constructed exception of the same type, the real failure is then pushed up above a second traceback, and the bottom line, the one you read first, names your copy instead of it.
Answer 4:
The output is five lines:
parse done: 1.5
parse done: n/a
skipped: n/a
parse done: 2.5
[1.5, 2.5]The "n/a" item is where the ordering matters. float("n/a") raises inside parse, and parse has no except, so it cannot handle the exception. But it does have a finally, and finally runs on the way out regardless of why control is leaving, including when an exception is in the middle of propagating. So parse done: n/a prints first, while the exception is still traveling, and only then does the exception arrive at load, where the except ValueError catches it and prints skipped: n/a.
That ordering surprises people because the two prints are in different functions and the one that "belongs" to the failure appears second. The rule that resolves it is that a finally belongs to the block it is attached to, and it completes before the exception is allowed to leave that block. Cleanup happens innermost-first, on the way up, before any handler further out sees anything.
The successful items show the other half of the same rule: parse done: 1.5 prints even though nothing went wrong, because finally also runs on the ordinary return path. It is not an error handler, it is an exit handler.
DON'T read finally as "runs after the error is handled", it runs before any handler outside its own block gets the exception at all.
Assignment
Assignment: A Two-Level Pipeline, and the finally That Ate an Exception
Build the following in one file, in this order.
Write a low-level parse_row(line) that splits "id,label,score", casts the id to an int and the score to a float, and raises on any problem. It must contain no try at all, its only job is to detect and let the failure rise.
Write a high-level run(lines) that loops over the rows, catches per-row ValueError, collects the good records, counts the failures, and uses finally to print a one-line summary of how many were processed and how many skipped. After the loop, if not a single row succeeded, raise your own AllRowsFailedError, a custom exception class you define, chaining it from the last error you saw so the original cause is preserved.
Demonstrate on a mixed list and on an all-bad list, catching the custom error at the call site and printing both it and its underlying cause.
Then demonstrate the trap. Write parse_swallowing(line), identical to parse_row except that its body sits in a try whose finally contains a return. Call it on a line that certainly cannot parse, print what comes back, and add a comment stating what happened to the exception and why nothing was raised.
Hints:
class AllRowsFailedError(Exception): passis a complete custom exception, andraise AllRowsFailedError(msg) from errrecordserras the cause, readable afterwards as the raised exception's__cause__.- For the trap, work out what a
returninsidefinallydoes to control flow when an exception is already on its way out of the block.
Solution:
# A custom exception is a class inheriting from Exception. No body is needed.
class AllRowsFailedError(Exception):
pass
# LOW LEVEL: detects and raises, never catches. It has no idea what the
# program wants done about a bad row, so it does not decide.
def parse_row(line):
# raises on the wrong field count
id_str, label, score_str = line.split(",")
return {"id": int(id_str), "label": label, "score": float(score_str)}
# HIGH LEVEL: catches, because here the policy 'skip bad rows' is known.
def run(lines):
good = []
skipped = 0
last_error = None
try:
for line in lines:
try:
good.append(parse_row(line))
except ValueError as err:
skipped += 1
last_error = err # keep it, so the cause survives the loop
if not good:
raise AllRowsFailedError(f"all {skipped} rows failed") from last_error
return good
finally:
print(f"processed {len(good)}, skipped {skipped}")
print(run(["1,cat,0.9", "2,dog,n/a", "3,bird,0.7"]))
try:
run(["1,cat,n/a", "2"])
except AllRowsFailedError as err:
print(type(err).__name__, "|", err)
print("underlying cause:", type(err.__cause__).__name__, "|", err.__cause__)The finally on run prints its summary on both calls, including the one that ends by raising, which is the guarantee being demonstrated. The summary appears before the handler at the call site does, for the same reason as ever, cleanup completes before the exception is allowed to leave the block.
# THE TRAP: a 'return' inside 'finally'.
def parse_swallowing(line):
try:
id_str, label, score_str = line.split(",")
return {"id": int(id_str), "label": label, "score": float(score_str)}
finally:
# discards everything
return {"id": None, "label": None, "score": None}
print(parse_swallowing("2"))
# {'id': None, 'label': None, 'score': None}
# The 'ValueError' was raised and then destroyed. A 'return' in 'finally'
# replaces whatever the block was doing, including an exception in flight, so
# the function returns normally and no exception tells anyone that it failed.The two blocks are the same lesson from opposite ends.
run uses finally correctly: for a side effect that must happen on every path, printing a summary, while leaving control flow alone. The exception still reaches the caller, the return value is still the list of good rows, and finally only added the reporting.
parse_swallowing uses finally to change the outcome, and a return there does not merely override the try's return value, it overrides an exception in flight. The ValueError is discarded mid-propagation, no traceback is ever printed, and the caller receives a plausible-looking record of None values. Every downstream except written for a bad row then stays silent, because there is no bad row any more, only a good row full of nothing.
This is why the rule is narrow and absolute: put side effects in finally, never control flow. No return, no break, no continue. The block exists to guarantee cleanup happens, not to decide what the function does.
DON'T put a return inside a finally, and don't reach for a custom exception before a built-in fits, AllRowsFailedError earns its place only because no built-in type means "every row of the input failed".
Additional Resources
Optional, and nothing later depends on either. Worth opening if you want the chaining syntax or the exact rules of try in full.
- Official docs - PEP 3134, "Exception Chaining and Embedded Tracebacks". The proposal that introduced
raise ... from ..., for exactly what the chain records and why both exceptions print. - Official docs - The Python Language Reference, "The try statement", the exact rules for
exceptandfinally, including what areturninsidefinallydoes.
Summary
Handling exceptions is where your programs stop being fragile. The previous lesson left you able to read a crash, but reading is after the fact, the program still died. With try/except you intervene during the failure, deciding which errors are expected facts of life to be absorbed and which are genuine problems to let through. The whole art is in that distinction. A malformed row in a real dataset is not a bug, it is Tuesday, and the right response is to skip it and keep loading. A NameError from your own typo is a bug, and the right response is to let it crash so you find it. Catching specific types is what keeps those two cases apart, which is why the bare except: is a trap and naming the exception is a discipline.
The supporting pieces each have a clear role. finally guarantees cleanup on every path out of a block, the same promise with makes for files, generalized to any resource or state you must restore. raise lets your own code enforce its contracts, failing loudly at the true source with a message that explains itself, rather than returning a plausible wrong answer that corrupts everything downstream. And propagation ties it all together: an exception rises through the call stack until something catches it, which means you get to choose the level where handling happens. Low-level code detects and raises because it lacks the context to respond, high-level code catches because it owns the policy. That separation, mechanism below, policy above, is the same propagation you watched produce the traceback frames in the last lesson, now turned into a design principle.
What remains is to stop errors before they start. Handling is reactive, you wait for the exception and respond, but the cheapest error is the one that never occurs because you checked the input at the door and refused the impossible case immediately. The next lesson turns to that proactive stance: validating arguments at a function's boundary, asserting the invariants your code depends on, failing fast at the first sign of an unrecoverable problem, and, when a bug does slip through, isolating it methodically instead of guessing. Resilience and prevention are two halves of the same craft, and you now have the first half.
Cheat Sheet
Part A - Reference Table
| Construct | Purpose | Key rule |
|---|---|---|
try: |
wrap risky code | keep it to the lines that can raise |
except TypeError: |
handle one type and its subclasses | name the narrowest type that fits |
except (A, B): |
handle several types | tuple of types, one handler |
except ... as err: |
bind the exception | use err, type(err).__name__ |
finally: |
always runs | cleanup only, no return |
raise T("msg") |
signal an error | specific type, clear message |
raise (bare) |
re-raise current | react without hiding |
raise B from err |
chain cause | preserves original in __cause__ |
| propagation | uncaught rises up | caught at nearest matching try |
Part B - Compact Code Block
# Per-row parse: skip bad rows, keep good ones.
records = []
for line in lines:
try:
a, b = line.split(",")
records.append({"id": int(a), "score": float(b)})
except ValueError: # bad number or wrong field count
continue # skip this row, keep going
# Guaranteed cleanup with finally.
f = open("x.txt", encoding="utf-8")
try:
data = int(f.read())
finally:
f.close() # runs on success AND on error
# Raise to enforce contracts, catch where policy lives.
def mean(xs):
if not xs:
raise ValueError("mean() needs >=1 value")
return sum(xs) / len(xs)
# NEVER a bare 'except:', it hides bugs