Skip to content
C Codeloom
Python

Error Handling in Python: try, except, else, finally

A practical guide to Python exception handling — try, except, else, finally, raising and re-raising, custom exceptions, and the habits that make error handling helpful.

·8 min read · By Yash Kesharwani
Intermediate 10 min read

What you'll learn

  • How try/except catches exceptions
  • The role of else and finally
  • How to raise and re-raise exceptions
  • When to define a custom exception class
  • The exception hierarchy and how to catch the right things
  • Common error-handling antipatterns to avoid

Prerequisites

  • Comfortable with functions — see Functions

Things will go wrong: files won’t exist, networks will drop, users will type “abc” into the age field. Python’s exception system lets you describe what should happen and handle the unexpected separately. Done well, exception handling makes your programs both shorter and more robust. Done poorly, it hides bugs and makes them harder to find. This post covers both sides.

Exceptions, briefly

When something goes wrong, Python raises an exception — an object that describes what happened. Unhandled, an exception travels up the call stack and stops the program with a traceback.

print(int("abc"))
# ValueError: invalid literal for int() with base 10: 'abc'

Every exception has a type (ValueError here) and usually a message. Common ones include ValueError, TypeError, KeyError, IndexError, FileNotFoundError, ZeroDivisionError, and AttributeError.

try and except

To handle an exception, wrap the risky code in try and write an except block for the type(s) you can recover from:

def parse_age(raw):
    try:
        return int(raw)
    except ValueError:
        return None

print(parse_age("30"))     # 30
print(parse_age("abc"))    # None

The try block runs to completion if no exception occurs. If one matches the except, control jumps there and the rest of the try block is skipped.

You can name the exception object with as:

try:
    int("abc")
except ValueError as e:
    print(f"Could not parse: {e}")
# Could not parse: invalid literal for int() with base 10: 'abc'

You can also catch multiple types with a tuple:

try:
    do_something()
except (ValueError, TypeError) as e:
    print(f"Bad input: {e}")

Or write several except blocks for different responses:

try:
    do_something()
except FileNotFoundError:
    print("File missing — using defaults.")
except PermissionError:
    print("No permission — check file mode.")

Python tries each except in order and uses the first that matches the exception class (or a superclass).

Catch specific exceptions

A bare except: (or except Exception:) catches almost everything, including bugs in your own code. That is rarely what you want:

# Bad
try:
    process(data)
except Exception:
    print("something went wrong")

# Good
try:
    process(data)
except (ValueError, KeyError) as e:
    print(f"bad data: {e}")

Catch the specific failures you can actually handle. Let everything else propagate so it produces a useful traceback. A program that silently swallows every error is harder to debug than one that crashes loudly.

else and finally

A try statement can have two more clauses.

else runs when the try block finished without raising. Use it to separate “the recovery path” from “the success path”:

def load_config(path):
    try:
        f = open(path)
    except FileNotFoundError:
        print("No config — using defaults.")
        return {}
    else:
        with f:
            return parse(f.read())

Keeping parse outside the try means you don’t accidentally catch ValueErrors from inside it.

finally runs no matter what — exception or not, with or without an except matching. Use it for cleanup that must happen even on failure:

def write_audit(message):
    log = open("audit.log", "a")
    try:
        log.write(message + "\n")
    finally:
        log.close()

In practice, with statements (covered in File I/O) handle most cleanup more cleanly. finally is still useful for non-file resources or for transactional rollback logic.

The full ordering is try → optional excepts → optional else → optional finally. At least one of except and finally must be present.

Try it yourself. Write a function safe_divide(a, b) that returns a / b, or None if b is zero. Use try/except with ZeroDivisionError. Then write safe_index(items, i) that returns items[i] or None if i is out of range, catching IndexError.

Raising exceptions

You raise an exception with the raise keyword:

def withdraw(balance, amount):
    if amount <= 0:
        raise ValueError("amount must be positive")
    if amount > balance:
        raise ValueError("insufficient funds")
    return balance - amount

Raising early at the boundary of your function makes the rest of the code free to assume valid inputs. Pick the most appropriate built-in exception type:

  • ValueError — argument has the right type but wrong value
  • TypeError — argument has the wrong type
  • KeyError / IndexError — missing key or out-of-range index
  • FileNotFoundError — file not present
  • NotImplementedError — a stub you have not finished

If none fit, define your own (see below) before reaching for Exception itself.

Re-raising

Sometimes you want to catch an exception, do something (log it, clean up), then let it propagate. A bare raise re-raises the current exception:

def process_record(record):
    try:
        validate(record)
        save(record)
    except ValueError:
        log.error("validation failed for %r", record)
        raise

To raise a new exception that wraps the original, use raise ... from:

def load_user(user_id):
    try:
        return database.get(user_id)
    except KeyError as e:
        raise LookupError(f"user {user_id} not found") from e

The from clause records the original cause in the traceback. It is the right tool when you want a high-level message but still want the low-level detail available.

Custom exception classes

Define your own exception types when you want callers to catch them specifically. Inherit from Exception (or a more specific subclass):

class ConfigError(Exception):
    """Raised when configuration is invalid."""

class MissingKeyError(ConfigError):
    """Raised when a required config key is absent."""

The body of the class can be empty — the docstring alone is fine. With a hierarchy, callers can be as specific or as general as they need:

try:
    load_config()
except MissingKeyError:
    ...
except ConfigError:
    ...

This is how the standard library is structured: FileNotFoundError, PermissionError, and so on all inherit from OSError.

The exception hierarchy

A simplified view of the exception tree:

BaseException
 ├── SystemExit
 ├── KeyboardInterrupt
 └── Exception
      ├── ArithmeticError
      │    └── ZeroDivisionError
      ├── LookupError
      │    ├── IndexError
      │    └── KeyError
      ├── ValueError
      ├── TypeError
      └── OSError
           ├── FileNotFoundError
           └── PermissionError

Two rules follow from this:

  1. Catch Exception, not BaseException. BaseException includes KeyboardInterrupt and SystemExit. Catching those usually means your program ignores Ctrl+C.
  2. Catching a parent catches all children. except OSError catches both FileNotFoundError and PermissionError.

EAFP and LBYL

Python culture has names for two error-handling philosophies:

  • LBYL — “Look Before You Leap.” Check first, act second.
  • EAFP — “Easier to Ask Forgiveness than Permission.” Try the action, handle failure.
# LBYL
if "name" in user:
    print(user["name"])

# EAFP
try:
    print(user["name"])
except KeyError:
    pass

Python tends to favour EAFP because the alternative often has race conditions (the file existed when you checked but not when you opened it). For dictionary access specifically, .get() is even cleaner — see Python Dictionaries.

Neither philosophy is universally right. Use whichever expresses your intent most clearly.

Try it yourself. Define a BankError base class and two subclasses InsufficientFundsError and AccountFrozenError. Write a withdraw(balance, amount, frozen) function that raises the right subclass. Then catch them separately and print a tailored message for each.

A worked example

A small loader that parses integer values from a config file, with layered error handling:

class ConfigError(Exception):
    """Base class for configuration problems."""

class MissingFieldError(ConfigError):
    pass

class InvalidFieldError(ConfigError):
    pass


def load_port(config: dict) -> int:
    try:
        raw = config["port"]
    except KeyError as e:
        raise MissingFieldError("'port' is required") from e

    try:
        port = int(raw)
    except (TypeError, ValueError) as e:
        raise InvalidFieldError(f"'port' must be an integer, got {raw!r}") from e

    if not (1 <= port <= 65535):
        raise InvalidFieldError(f"'port' must be 1-65535, got {port}")

    return port


def main():
    for cfg in [{"port": "8080"}, {}, {"port": "abc"}, {"port": 70000}]:
        try:
            print("port:", load_port(cfg))
        except ConfigError as e:
            print("config error:", e)

main()
# port: 8080
# config error: 'port' is required
# config error: 'port' must be an integer, got 'abc'
# config error: 'port' must be 1-65535, got 70000

Each try block is narrow and catches one specific failure. main only has to know about ConfigError — the most general type — but every error message carries a precise reason.

Recap

You now know:

  • Exceptions describe what went wrong; unhandled, they stop the program
  • try/except lets you recover from specific exceptions
  • else runs only on success; finally runs always — use it for cleanup
  • Catch specific exceptions, not bare Exception
  • raise produces an exception; bare raise re-raises the current one
  • raise X() from e wraps a lower-level error in a higher-level one
  • Define custom exception classes to let callers catch your errors precisely
  • Python culture leans toward EAFP, but use whichever reads more clearly

Next steps

The next post applies what you have learned about errors in one of the places they show up most: working with files. We will cover reading, writing, and the with statement that closes files safely no matter what.

→ Next: Reading and Writing Files in Python

Questions or feedback? Email codeloomdevv@gmail.com.