Exceptions in Depth
Handle several error types, run cleanup code, and raise your own errors to write robust, professional programs.
What you will learn
- Catch specific exception types
- Use else and finally
- Raise your own exceptions with raise
A quick recap
You already met try / except: risky code goes in try, and if it fails, the except block runs instead of the program crashing. An exception is just Python’s name for a runtime error — like dividing by zero or converting bad text to a number. Now we go deeper, because real programs need to react differently to different problems.
Catching specific error types
Different mistakes raise different exception types, each with a name: ValueError (bad value, like int("abc")), ZeroDivisionError (dividing by zero), KeyError (missing dictionary key), and so on. You can write a separate except for each so you give the right message for the right problem.
def divide(text):
try:
n = int(text)
return 100 / n
except ValueError:
return "That was not a number"
except ZeroDivisionError:
return "Cannot divide by zero"
print(divide("4")) # works
print(divide("abc")) # ValueError
print(divide("0")) # ZeroDivisionErrorPython tries the try block, and if it fails it jumps to the first matching except. With "4" nothing fails, so we get 25.0. With "abc", int() raises a ValueError, so that block runs. With "0", the conversion works but 100 / 0 raises a ZeroDivisionError, caught by its own block. Naming the type lets each problem get its own tailored response.
Note: Output: 25.0 That was not a number Cannot divide by zero
else and finally
Two more parts make error handling complete. An else block runs only if the try succeeded (no error). A finally block runs no matter what — error or not — which is perfect for cleanup like closing a file or a connection.
try:
age = int("25")
except ValueError:
print("Bad input")
else:
print("Parsed fine:", age)
finally:
print("Done checking")Here int("25") succeeds, so: the except is skipped; the else runs because there was no error, printing Parsed fine: 25; and finally always runs, printing Done checking. If the input had been bad, the except would print Bad input, the else would be skipped, and finally would still print Done checking. That guarantee is why finally is used for cleanup.
Note: Output: Parsed fine: 25 Done checking
Raising your own errors
Sometimes your own code should refuse bad data — for example, an age can never be negative. The raise keyword lets you trigger an exception on purpose, stopping the bad value from sneaking through. You choose the type and the message.
def set_age(age):
if age < 0:
raise ValueError("Age cannot be negative")
return age
try:
set_age(-5)
except ValueError as err:
print("Rejected:", err)Inside set_age, if the age is below zero we raise ValueError("Age cannot be negative") — this immediately stops the function and signals a problem. The caller wraps the call in try, and except ValueError as err catches it; as err captures the exception object so we can print its message. So we get Rejected: Age cannot be negative instead of a silently-stored bad value.
Note: Output: Rejected: Age cannot be negative
Tip: Catch the most specific error you can, and avoid a bare except: that swallows everything — it can hide real bugs. Catching Exception is the broadest you should usually go.
Q. Which block runs whether or not an error occurred?
✍️ Practice
- Write a function that catches both ValueError and ZeroDivisionError separately.
- Use raise to reject an empty username with a clear message.
🏠 Homework
- Build a safe_divide(a, b) that returns the result, but raises a ValueError with a helpful message when b is 0, and test it with try/except.