Skip to main content
Python · lesson 19 of 34
Python intermediate Lesson 19 of 34

Also part of: Data Engineering

Error Handling in Python

Handle exceptions with try/except/else/finally, chain and re-raise them, define custom exceptions, use except* and logging, all with real tracebacks.

By SkillByExample · Updated

When something goes wrong, Python raises an exception object that travels up the call stack until a matching except catches it, or the program stops with a traceback. Output below is from Python 3.13.

The exception hierarchy

Exceptions are classes, so catching a parent catches all its children.

for exc in (ZeroDivisionError, KeyError, FileNotFoundError, KeyboardInterrupt):
    print(f"{exc.__name__:18}", " -> ".join(c.__name__ for c in exc.__mro__[1:]))
$ python hierarchy.py
ZeroDivisionError  ArithmeticError -> Exception -> BaseException -> object
KeyError           LookupError -> Exception -> BaseException -> object
FileNotFoundError  OSError -> Exception -> BaseException -> object
KeyboardInterrupt  BaseException -> object

except LookupError catches both KeyError and IndexError; except OSError catches every file-system error. KeyboardInterrupt (Ctrl-C) and SystemExit sit outside Exception, so except Exception deliberately does not stop a user from interrupting your program.

An uncaught exception

The traceback reads bottom-up: the last line is the error, the frames above it are how you got there.

def average(values):
    return sum(values) / len(values)

def report(scores):
    return f"mean={average(scores):.1f}"

print(report([90, 80]))
print(report([]))
$ python uncaught.py
mean=85.0
Traceback (most recent call last):
  File "/home/user/py-demo/error-handling/uncaught.py", line 8, in <module>
    print(report([]))
          ~~~~~~^^^^
  File "/home/user/py-demo/error-handling/uncaught.py", line 5, in report
    return f"mean={average(scores):.1f}"
                   ~~~~~~~^^^^^^^^
  File "/home/user/py-demo/error-handling/uncaught.py", line 2, in average
    return sum(values) / len(values)
           ~~~~~~~~~~~~^~~~~~~~~~~~~
ZeroDivisionError: division by zero

The first call printed; the second raised in average, two calls deep. With no except anywhere, the exception unwound every frame and ended the program — and the ^ markers point at the exact sub-expression that failed.

try / except / else / finally

Each clause has a distinct job; the order they run in is the lesson.

import json

def load(text: str) -> dict:
    try:
        data = json.loads(text)
    except json.JSONDecodeError as e:
        print(f"  except: bad JSON at char {e.pos}")
        return {}
    else:
        print("  else: parsed OK")
        return data
    finally:
        print("  finally: always")

for text in ['{"debug": true}', '{debug: true}']:
    print(text)
    print("  result:", load(text))
$ python try_clauses.py
{"debug": true}
  else: parsed OK
  finally: always
  result: {'debug': True}
{debug: true}
  except: bad JSON at char 1
  finally: always
  result: {}

else ran only on success, except only on failure, and finally ran both times — after the return statement inside else or except had computed its value but before the function actually returned. Keeping the success path in else means a bug there won’t be mistaken for a JSON error.

Catching several types, and re-raising

A tuple catches several types in one clause; a bare raise re-raises the current exception unchanged.

def parse_ratio(text: str) -> float:
    try:
        a, b = text.split("/")
        return int(a) / int(b)
    except (ValueError, ZeroDivisionError) as e:
        print(f"bad ratio {text!r}: {type(e).__name__}: {e}")
        return float("nan")

for t in ["3/4", "3/0", "three/4", "1/2/3"]:
    print(parse_ratio(t))

def strict(text: str) -> float:
    try:
        return int(text)
    except ValueError:
        print("logging, then re-raising")
        raise

try:
    strict("x")
except ValueError as e:
    print("caller got:", e)
$ python multiple.py
0.75
bad ratio '3/0': ZeroDivisionError: division by zero
nan
bad ratio 'three/4': ValueError: invalid literal for int() with base 10: 'three'
nan
bad ratio '1/2/3': ValueError: too many values to unpack (expected 2)
nan
logging, then re-raising
caller got: invalid literal for int() with base 10: 'x'

"1/2/3" failed in a place you might not expect — tuple unpacking, not int() — but it raises ValueError too, so the same clause handled it. strict logged and re-raised, so the caller still received the original exception with its traceback intact.

Custom exceptions and chaining

A small hierarchy of your own exceptions lets callers catch exactly the failures they can handle.

class AppError(Exception):
    """Base class for this application's errors."""

class NotFoundError(AppError):
    def __init__(self, resource: str, id_: int):
        super().__init__(f"{resource} {id_} not found")
        self.resource, self.id = resource, id_

USERS = {1: "alice"}

def get_user(user_id: int) -> str:
    try:
        return USERS[user_id]
    except KeyError as e:
        raise NotFoundError("user", user_id) from e

try:
    get_user(7)
except AppError as e:
    print(type(e).__name__, "|", e, "|", e.resource, e.id)
    print("cause:", repr(e.__cause__))

get_user(8)
$ python custom.py
NotFoundError | user 7 not found | user 7
cause: KeyError(7)
Traceback (most recent call last):
  File "/home/user/py-demo/error-handling/custom.py", line 13, in get_user
    return USERS[user_id]
           ~~~~~^^^^^^^^^
KeyError: 8

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/user/py-demo/error-handling/custom.py", line 23, in <module>
    get_user(8)
    ~~~~~~~~^^^
  File "/home/user/py-demo/error-handling/custom.py", line 15, in get_user
    raise NotFoundError("user", user_id) from e
NotFoundError: user 8 not found

except AppError caught the subclass, and the extra attributes travelled with it. from e stored the KeyError as __cause__, so the uncaught case prints both tracebacks — the low-level KeyError and the meaningful NotFoundError — joined by “direct cause”.

Exception groups and except* (3.11+)

When several things fail at once — typically concurrent tasks — Python raises an ExceptionGroup, and except* handles each type inside it.

def run_all(jobs):
    errors = []
    for job in jobs:
        try:
            job()
        except Exception as e:
            errors.append(e)
    if errors:
        raise ExceptionGroup("some jobs failed", errors)

def bad_value(): raise ValueError("bad config")
def timeout(): raise TimeoutError("db timed out")
def ok(): pass

try:
    run_all([bad_value, ok, timeout, timeout])
except* ValueError as eg:
    print("value errors:", [str(e) for e in eg.exceptions])
except* TimeoutError as eg:
    print(f"{len(eg.exceptions)} timeouts")
$ python groups.py
value errors: ['bad config']
2 timeouts

Unlike ordinary except, both except* clauses ran: each one takes the matching exceptions out of the group. asyncio.TaskGroup raises exactly this kind of group (see async).

Logging instead of print

logging adds levels, a logger name and, via logger.exception, the full traceback.

import logging
import sys

logging.basicConfig(stream=sys.stdout, level=logging.INFO,
                    format="%(levelname)s %(name)s: %(message)s")
logger = logging.getLogger("orders")

def process_order(order_id: int) -> None:
    logger.info("processing order %d", order_id)
    logger.debug("not shown at INFO level")
    try:
        {}["items"]
    except KeyError:
        logger.exception("order %d failed", order_id)

process_order(42)
$ python logs.py
INFO orders: processing order 42
ERROR orders: order 42 failed
Traceback (most recent call last):
  File "/home/user/py-demo/error-handling/logs.py", line 12, in process_order
    {}["items"]
    ~~^^^^^^^^^
KeyError: 'items'

The debug line was filtered out by level=logging.INFO. logger.exception logs at ERROR and appends the traceback of the exception being handled, and the program carried on afterwards. Pass values as arguments (%d, order_id) rather than f-strings so formatting is skipped for filtered-out messages. (Real apps usually add %(asctime)s to the format; it’s left out here so the output is reproducible.)

The silent-swallow bug

except Exception: pass hides bugs, not just the errors you expected.

def send_email(user: dict) -> None:
    print("sending to", user["emial"])      # typo

def notify_all(users):
    for u in users:
        try:
            send_email(u)
        except Exception:
            pass

def notify_all_better(users):
    for u in users:
        try:
            send_email(u)
        except ConnectionError as e:
            print("will retry:", e)

notify_all([{"email": "a@example.com"}])
print("notify_all finished; nobody was emailed")
notify_all_better([{"email": "a@example.com"}])
$ python swallow.py
notify_all finished; nobody was emailed
Traceback (most recent call last):
  File "/home/user/py-demo/error-handling/swallow.py", line 20, in <module>
    notify_all_better([{"email": "a@example.com"}])
    ~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/user/py-demo/error-handling/swallow.py", line 14, in notify_all_better
    send_email(u)
    ~~~~~~~~~~^^^
  File "/home/user/py-demo/error-handling/swallow.py", line 2, in send_email
    print("sending to", user["emial"])      # typo
                        ~~~~^^^^^^^^^
KeyError: 'emial'

The first version “succeeded” while doing nothing — the typo’s KeyError was swallowed. Catching only the error you can handle (ConnectionError) let the bug surface immediately, pointing at the typo.

Practice

1. Write safe_int(text, default=0) that returns default for anything int() can’t parse — including None — and test it on "42", "x" and None.

Solution
def safe_int(text, default=0):
    try:
        return int(text)
    except (ValueError, TypeError):
        return default

print(safe_int("42"), safe_int("x"), safe_int(None), safe_int("x", default=-1))
$ python safe_int.py
42 0 0 -1

int("x") raises ValueError but int(None) raises TypeError, so both must be listed.

2. Add a ValidationError(AppError) with a field attribute, raise it from create_user when age is negative, and catch it by the base class.

Solution
class AppError(Exception):
    pass

class ValidationError(AppError):
    def __init__(self, field: str, message: str):
        super().__init__(f"{field}: {message}")
        self.field = field

def create_user(name: str, age: int) -> dict:
    if age < 0:
        raise ValidationError("age", "must not be negative")
    return {"name": name, "age": age}

for age in (30, -1):
    try:
        print(create_user("ana", age))
    except AppError as e:
        print("rejected:", e, "| field =", e.field)
$ python validation.py
{'name': 'ana', 'age': 30}
rejected: age: must not be negative | field = age

3. Show that finally runs even when the try block returns, by returning from inside try and printing in finally.

Solution
def f():
    try:
        print("try")
        return "from try"
    finally:
        print("finally")

print(f())
$ python finally_return.py
try
finally
from try

The return value is computed first, then finally runs, then the function actually returns.

4. Change custom.py’s raise ... from e to from None and run get_user(8) uncaught. What disappears from the traceback?

Solution
class NotFoundError(Exception):
    pass

USERS = {1: "alice"}

def get_user(user_id: int) -> str:
    try:
        return USERS[user_id]
    except KeyError:
        raise NotFoundError(f"user {user_id} not found") from None

get_user(8)
$ python from_none.py
Traceback (most recent call last):
  File "/home/user/py-demo/error-handling/from_none.py", line 12, in <module>
    get_user(8)
    ~~~~~~~~^^^
  File "/home/user/py-demo/error-handling/from_none.py", line 10, in get_user
    raise NotFoundError(f"user {user_id} not found") from None
NotFoundError: user 8 not found

The KeyError section is gone: from None suppresses the context, which is right when the low-level error is an implementation detail.

Frequently Asked Questions

Should I catch Exception broadly?
Rarely. A broad except Exception also catches the bugs you did not anticipate, such as a typo raising NameError, and hides them. Catch the specific exception types you expect, and if you need a catch-all at a program boundary, log the full traceback with logger.exception.
When should I raise vs return an error?
Raise an exception when the function cannot do what its name promises and the caller must deal with it. Return a value such as None for an expected, ordinary outcome, like a lookup that finds nothing, where the caller will check it anyway.
What's the difference between except and finally?
An except block runs only when a matching exception was raised in the try block. A finally block always runs — after success, after an exception, and even when the try block executes a return statement.
What does raise ... from e do?
It raises a new exception and records the original as its __cause__, so the traceback shows both, joined by 'The above exception was the direct cause of the following exception'. Use from None to deliberately hide the original.
When does the else block of a try statement run?
Only when the try block completed without raising. It is the place for code that should run on success but whose own exceptions you do not want the preceding except clauses to catch.