Module 4 · Functions & Modularity ⏱ 19 min

Errors & Exceptions

By the end of this lesson you will be able to:
  • Wrap risky code in try/except so an error does not crash the whole program
  • Catch specific exception types instead of every possible error
  • Use try/except/else/finally to handle both success and cleanup
  • Raise your own exceptions when code detects a problem it cannot fix
  • Recognise the bare except trap that hides real bugs

When something goes wrong in Python — dividing by zero, reading a missing key, parsing bad text — it raises an exception. Left alone, an exception stops the program immediately and prints a traceback: a report of exactly where things broke and what call chain led there.

That is helpful while you are debugging, but it is disastrous in a live program. A user entering a bad number should not crash the whole application. A try/except block lets you catch the exception and respond gracefully:

def safe_divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        return None

Code that might fail goes in try; the recovery plan goes in except. The program survives, and you decide what to do next — return a default, ask again, or log the issue. Without this safety net, every risky operation would need its own guard logic, and your code would drown in checks.

flowchart TD
  T["try: risky code"] -->|no error| E["else / continue"]
  T -->|raises| X["except: handle it"]
  E --> F["finally: cleanup"]
  X --> F
  style T fill:#3776ab,color:#fff
  style X fill:#b91c1c,color:#fff
try runs the risky code; on an error, control jumps to a matching except; finally always runs.

Catch specific types

Always catch a specific exception type so real bugs still surface. except ValueError catches bad conversions; except ZeroDivisionError catches division by zero. Each type is a signal about what went wrong, and your recovery should match the failure.

You can list several types in one except, and you can chain handlers from most specific to least specific:

try:
    value = int('not a number')
except ValueError:
    print('that was not a number')

Catching the specific type is also documentation: the next person to read your code knows exactly which failure you expected. A vague handler says "something might go wrong"; a specific one says "I know this input can be invalid, and here is the plan." If you catch everything, you catch bugs you did not know about — and those are the ones you most need to see.

Catch specific exceptions and return safe defaults.
def safe_divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        return None

print(safe_divide(10, 2))
print(safe_divide(10, 0))

try:
    int('oops')
except ValueError:
    print('caught a ValueError')

else and finally

Two optional blocks make try more powerful.

else runs only when the try block succeeds — no exception was raised. It keeps the happy path visible and separate from the error handling. This matters for readability: when else is present, a reader knows the code inside it assumes everything worked.

finally runs no matter what: success, failure, or even an early return. It is the right place for cleanup — closing files, releasing locks, or resetting state.

Together they give you a complete lifecycle: attempt the work, handle the expected failures, do the next step on success, and clean up either way. A function with all four blocks is rare, but knowing they exist lets you choose the right shape for the job.

else runs on success; finally runs every time.
def process(value):
    try:
        n = int(value)
    except ValueError:
        print('bad input')
        return
    else:
        print('parsed:', n)
    finally:
        print('cleanup done')

process('42')
process('hi')
flowchart TD
  T["try"] -->|success| E["else"]
  T -->|error| X["except"]
  E --> F["finally"]
  X --> F
  style T fill:#3776ab,color:#fff
  style E fill:#16a34a,color:#fff
  style X fill:#b91c1c,color:#fff
  style F fill:#1e293b,color:#fff
else runs only when try succeeds; finally runs in every outcome.

Raising your own exceptions

You are not limited to the exceptions Python provides. When your function detects a problem it cannot fix, raise an exception and let the caller decide what to do:

def set_age(age):
    if age < 0:
        raise ValueError('age cannot be negative')
    return age

Raising an exception is better than returning a magic value like -1 or None, because those can be silently ignored. An exception forces the caller to handle the problem or admit they are ignoring it. Use built-in types like ValueError and TypeError when they fit; create custom exceptions only when you need a type no built-in covers.

The as keyword lets you capture the exception object and read its message: except ValueError as e gives you e, which is useful for logging or displaying friendly error text.

Raise an exception when input breaks a business rule.
def set_age(age):
    if age < 0:
        raise ValueError('age cannot be negative')
    return age

try:
    print(set_age(25))
    print(set_age(-3))
except ValueError as e:
    print('caught:', e)
Exercise

Write safe_int(value) that returns int(value) when it can be parsed, and returns 0 otherwise. Catch ValueError (and TypeError, in case value is not a string or number).

def safe_int(value):
    # try int(value); on failure return 0
    pass
Exercise

What does this print? The division by zero is caught, and finally always runs.

try:
    result = 10 // 0
    print('ok')
except ZeroDivisionError:
    print('caught')
finally:
    print('done')
Exercise

Write validate_positive(n) that returns n if it is greater than zero. Otherwise raise ValueError with the exact message 'must be positive'.

def validate_positive(n):
    # raise ValueError if n <= 0
    pass
Exercise

This function is meant to catch ValueError when parsing fails, but its bare except also hides NameError bugs. Fix it to catch only ValueError.

def parse_number(text):
    try:
        return int(text)
    except:
        return 0
Exercise

Write divide_all(numbers, divisor) that returns a list of n / divisor for each n in numbers. If divisor is zero, return an empty list instead of crashing. Use try/except inside the loop.

def divide_all(numbers, divisor):
    # return list of n/divisor, or empty list if divisor is 0
    pass

Recap

  • try holds code that might fail; except catches a specific exception type and recovers.
  • else runs only when try succeeds; finally runs no matter what.
  • raise lets you signal problems your code cannot fix, forcing the caller to handle them.
  • A bare except: hides real bugs — always name the type you expect.
  • Specific handlers are documentation: they tell the reader exactly which failure you planned for.
  • Use except ExceptionType as e to capture the error message for logging or user feedback.

Next you'll meet functions, the tool that lets you wrap this error-handling logic into a reusable, testable unit.

Checkpoint quiz

Which block holds the code that might raise an error?

Why prefer except ValueError: over a bare except:?

Go deeper — technical resources