Error Handling Basics
Topic 8 of 8, with 4 concept checks. try/except/finally, and raising your own exceptions
Separate expected failures from broken assumptions
Failure boundaries
Place exception handling around the operation that may legitimately fail, catch only the errors you can resolve, and preserve unexpected failures for diagnosis.
Core lesson 01
Prefer except Exception (or a specific type) over a bare except: — bare except also swallows KeyboardInterrupt and SystemExit.
Python's exception hierarchy has BaseException at the root, with Exception as one major branch and things like SystemExit and KeyboardInterrupt as siblings, not children, of Exception. A bare `except:` catches everything under BaseException, including those control-flow signals — which means Ctrl+C might get silently swallowed by an overly broad except block. `except Exception:` catches ordinary errors while letting those system-level signals propagate as intended.
try:
result = 10 / 0
except Exception as e:
print(f"Handled: {type(e).__name__}: {e}")
# avoid this pattern:
# try:
# ...
# except:
# pass # hides real bugs AND Ctrl+CWhat to remember
What's the difference between except Exception: and a bare except:?
Common footguns
- Using bare `except: pass` to 'make errors go away' — this hides real bugs and makes debugging painful later.
Core lesson 02
finally always runs — success, exception, or even a return in try/except — making it the right place for guaranteed cleanup.
`finally` is Python's guarantee: whatever happens inside the `try`/`except` above it — normal completion, an exception, or even a `return`/`break` — the `finally` block still executes before control actually leaves. This makes it the correct place for cleanup that must happen no matter what, like releasing a lock or closing a file (though a `with` context manager is usually cleaner for that specific case).
def read_value(d, key):
print("attempting lookup")
try:
return d[key]
except KeyError:
return None
finally:
print("cleanup always runs")
print(read_value({"a": 1}, "a"))
print(read_value({"a": 1}, "b"))What to remember
What is the purpose of the finally block?
Common footguns
- Putting a `return` inside `finally` — it silently overrides any return/exception from the try/except, which is almost always a bug.
try: Exception flow:
risky() ---> raised in risky()
except ValueError:
handle() <---- caught here, others propagate up
finally:
cleanup() <---- ALWAYS runs, success or failureCore lesson 03
class MyError(Exception): pass, then raise MyError('message') — subclass Exception (or a more specific built-in) and raise it like any other.
Custom exceptions let calling code catch and handle *your* specific failure modes distinctly from generic errors. You define them by subclassing Exception (or a more specific existing exception if that's semantically closer), optionally adding extra attributes or overriding `__init__` to carry structured error data, not just a message string.
class InsufficientFundsError(Exception):
def __init__(self, balance, amount):
super().__init__(f"Cannot withdraw {amount}, balance is {balance}")
self.balance = balance
self.amount = amount
def withdraw(balance, amount):
if amount > balance:
raise InsufficientFundsError(balance, amount)
return balance - amount
try:
withdraw(50, 100)
except InsufficientFundsError as e:
print(e, "| shortfall:", e.amount - e.balance)What to remember
How do you create and raise a custom exception?
Common footguns
- Subclassing BaseException directly instead of Exception — this makes your exception harder to catch with the conventional `except Exception` and signals 'this is as severe as SystemExit', which is rarely intended.
Core lesson 04
It explicitly chains exceptions, showing both the new error and its original cause in the traceback — preserving debugging context instead of hiding it.
When you catch one exception and raise a different, more meaningful one in response, Python still remembers the original by default (as `__context__`) and prints 'During handling of the above exception, another exception occurred'. Using `raise NewError(...) from original` makes that relationship explicit and intentional, changing the message to 'The above exception was the direct cause of the following exception' — clearer for anyone reading the traceback later.
def parse_config(raw):
try:
return int(raw)
except ValueError as e:
raise RuntimeError(f"invalid config value: {raw!r}") from e
try:
parse_config("not-a-number")
except RuntimeError as e:
print(e)
print("caused by:", e.__cause__)What to remember
What does raise NewError(...) from original_error do?
Common footguns
- Using `raise NewError(...)` with no `from` (or `from None`) when you actually want to suppress an unhelpful original traceback — `from None` explicitly says 'don't chain', which is different from forgetting to chain.
Python lab
Browser Python lab
Runtime · idle
Python loads on your first run. Your code stays in this browser.
Best practices
- Catch specific exceptions, not bare except: — makes bugs visible instead of silently hidden.
- Use finally (or a context manager) for guaranteed cleanup rather than hoping later code runs.
- Don't use exceptions for routine control flow when a simple check would do.
- Include a clear, actionable message whenever you raise an exception.
Apply the concept in Interview practice
ExceptionseasyHackerRank · O(1) per case
Wrap the division in try/except, catching ZeroDivisionError and ValueError separately and printing each exception's message.
Open problemIncorrect RegexeasyHackerRank · O(1) per pattern
Try re.compile(pattern) inside try/except re.error, printing True on success and False on failure.
Open problemString to Integer (atoi)mediumLeetCode #8 · O(n) time
Manually walk the string handling leading whitespace, an optional sign, then digits, clamping to the 32-bit signed range instead of raising — a good exercise in defensive parsing.
Open problemConcept checks
What's the difference between except Exception: and a bare except:?
Hint
One of these catches things like KeyboardInterrupt and SystemExit too.
Bare except is broader than you almost always want.
Answer
Prefer except Exception (or a specific type) over a bare except: — bare except also swallows KeyboardInterrupt and SystemExit.
Python's exception hierarchy has BaseException at the root, with Exception as one major branch and things like SystemExit and KeyboardInterrupt as siblings, not children, of Exception. A bare `except:` catches everything under BaseException, including those control-flow signals — which means Ctrl+C might get silently swallowed by an overly broad except block. `except Exception:` catches ordinary errors while letting those system-level signals propagate as intended.
try:
result = 10 / 0
except Exception as e:
print(f"Handled: {type(e).__name__}: {e}")
# avoid this pattern:
# try:
# ...
# except:
# pass # hides real bugs AND Ctrl+CWatch out
- Using bare `except: pass` to 'make errors go away' — this hides real bugs and makes debugging painful later.
What is the purpose of the finally block?
Hint
It runs regardless of whether an exception occurred.
Commonly used for cleanup, like closing a file or connection.
Answer
finally always runs — success, exception, or even a return in try/except — making it the right place for guaranteed cleanup.
`finally` is Python's guarantee: whatever happens inside the `try`/`except` above it — normal completion, an exception, or even a `return`/`break` — the `finally` block still executes before control actually leaves. This makes it the correct place for cleanup that must happen no matter what, like releasing a lock or closing a file (though a `with` context manager is usually cleaner for that specific case).
def read_value(d, key):
print("attempting lookup")
try:
return d[key]
except KeyError:
return None
finally:
print("cleanup always runs")
print(read_value({"a": 1}, "a"))
print(read_value({"a": 1}, "b"))try: Exception flow:
risky() ---> raised in risky()
except ValueError:
handle() <---- caught here, others propagate up
finally:
cleanup() <---- ALWAYS runs, success or failureWatch out
- Putting a `return` inside `finally` — it silently overrides any return/exception from the try/except, which is almost always a bug.
How do you create and raise a custom exception?
Hint
Subclass an existing exception, usually Exception itself.
Then use raise YourException('message').
Answer
class MyError(Exception): pass, then raise MyError('message') — subclass Exception (or a more specific built-in) and raise it like any other.
Custom exceptions let calling code catch and handle *your* specific failure modes distinctly from generic errors. You define them by subclassing Exception (or a more specific existing exception if that's semantically closer), optionally adding extra attributes or overriding `__init__` to carry structured error data, not just a message string.
class InsufficientFundsError(Exception):
def __init__(self, balance, amount):
super().__init__(f"Cannot withdraw {amount}, balance is {balance}")
self.balance = balance
self.amount = amount
def withdraw(balance, amount):
if amount > balance:
raise InsufficientFundsError(balance, amount)
return balance - amount
try:
withdraw(50, 100)
except InsufficientFundsError as e:
print(e, "| shortfall:", e.amount - e.balance)Watch out
- Subclassing BaseException directly instead of Exception — this makes your exception harder to catch with the conventional `except Exception` and signals 'this is as severe as SystemExit', which is rarely intended.
What does raise NewError(...) from original_error do?
Hint
It's about exception chaining, not just re-raising.
It preserves the original traceback context explicitly.
Answer
It explicitly chains exceptions, showing both the new error and its original cause in the traceback — preserving debugging context instead of hiding it.
When you catch one exception and raise a different, more meaningful one in response, Python still remembers the original by default (as `__context__`) and prints 'During handling of the above exception, another exception occurred'. Using `raise NewError(...) from original` makes that relationship explicit and intentional, changing the message to 'The above exception was the direct cause of the following exception' — clearer for anyone reading the traceback later.
def parse_config(raw):
try:
return int(raw)
except ValueError as e:
raise RuntimeError(f"invalid config value: {raw!r}") from e
try:
parse_config("not-a-number")
except RuntimeError as e:
print(e)
print("caused by:", e.__cause__)Watch out
- Using `raise NewError(...)` with no `from` (or `from None`) when you actually want to suppress an unhelpful original traceback — `from None` explicitly says 'don't chain', which is different from forgetting to chain.