Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Intermediate+150 XP

Exception Handling

try/except/else/finally in full, catching specific exceptions, the exception hierarchy, raising and chaining exceptions, and writing your own exception classes.

50 min August 2026
// Part 01 — try and except

What Happens Without Exception Handling — and How try/except Fixes It

By default, when Python hits a runtime error — dividing by zero, accessing a missing dictionary key, converting text that isn't a valid number — it raises an exception, and unless something catches it, the program stops immediately and prints a traceback. For a script that processes many independent items, one bad item shouldn't be allowed to bring down the whole run.

An unhandled exception stops the entire program
amounts = ["120.50", "89.99", "not-a-number", "45.00"]

total = 0
for amount in amounts:
    total += float(amount)   # crashes on the third item

print(total)   # never reached — the program already terminated
# ValueError: could not convert string to float: 'not-a-number'

try/except lets you catch an exception where it happens and decide what to do next, instead of letting it propagate all the way up and kill the program.

Catching the exception, and continuing
amounts = ["120.50", "89.99", "not-a-number", "45.00"]

total = 0
skipped = 0
for amount in amounts:
    try:
        total += float(amount)
    except ValueError:
        skipped += 1
        print(f"Skipping invalid amount: {amount!r}")

print(f"Total: {total}, skipped: {skipped}")
# Skipping invalid amount: 'not-a-number'
# Total: 255.49, skipped: 1

The code inside try: runs normally until (and unless) an exception occurs. The moment one is raised, Python immediately jumps to the matching except block — any remaining lines in the try block are skipped entirely, not just the line that failed.

// Part 02 — else and finally

The Four Clauses — What Each One Is Actually For

Most tutorials stop at try/except and skip the other two clauses entirely — but else and finally both solve real, specific problems, and knowing when to reach for each one is what separates tutorial-level exception handling from the way experienced engineers actually write it.

All four clauses, and when each one runs
try:
    result = risky_operation()
except SomeError:
    # runs ONLY if the try block raised SomeError
    handle_the_error()
else:
    # runs ONLY if the try block completed with NO exception at all
    use_the_result(result)
finally:
    # ALWAYS runs — whether an exception occurred, was caught, wasn't caught,
    # or the try block succeeded cleanly. Even if the except block itself
    # raises a new exception, finally still runs before it propagates.
    cleanup()

else — code that should run only when nothing went wrong

It is tempting to just put the "success" code at the end of the try block instead of using else — but that quietly changes what gets caught. Code inside try is protected by the except clauses below it; code inside else is not. This distinction matters the moment the "success" code can itself raise the same kind of exception you were trying to catch.

Why else matters — avoiding accidentally catching the wrong thing
# Without else — a bug hiding in plain sight
try:
    data = json.loads(text)
    process(data)          # if process() ALSO raises a ValueError, it gets
except ValueError:          # incorrectly caught here too, as if parsing had failed
    print("Invalid JSON")

# With else — only json.loads() failures are caught;
# any error from process() propagates normally, as it should
try:
    data = json.loads(text)
except ValueError:
    print("Invalid JSON")
else:
    process(data)

finally — guaranteed cleanup, no matter what happens

finally runs unconditionally — this is precisely the mechanism that makes with open(...), from the Reading & Writing Files module, work: internally, a context manager's cleanup step is guaranteed using logic equivalent to a finally block, which is exactly why the file gets closed even when an exception is raised partway through.

finally guarantees cleanup runs, even when an exception is re-raised
def process_file(path):
    f = open(path)
    try:
        return risky_parse(f)
    finally:
        f.close()   # runs whether risky_parse() succeeds, raises, or anything in between

# This is conceptually what "with open(path) as f:" does for you automatically —
# which is exactly why "with" is still preferred over writing this by hand.
// Part 03 — Specific vs Bare except

Catching Specific Exceptions vs the Bare except: Anti-Pattern

except: with no exception type at all will catch everything — not just the error you were expecting, but genuine programming mistakes, typos, and even the exceptions Python uses internally to implement Ctrl+C and program exit. This is one of the most consequential anti-patterns in exception handling, and it is worth understanding exactly why.

The bare except: anti-pattern — and what it silently swallows
try:
    process_order(order)
except:
    print("Something went wrong")

# This catches EVERYTHING, including:
# - a genuine bug like a typo: proces_order(order)  (NameError)
# - passing the wrong type entirely: order.total()  (TypeError, if total is not callable)
# - Ctrl+C during a long-running operation (KeyboardInterrupt)
# - the process being asked to exit cleanly (SystemExit)
#
# All of these get reduced to the same unhelpful message,
# and the ORIGINAL bug is now invisible.
The fix — catch exactly the exception types you expect and can handle
try:
    process_order(order)
except (ValueError, KeyError) as e:
    print(f"Order data was invalid: {e}")
    log_error(order, e)

# A genuine bug (NameError, TypeError from your own code) is NOT caught here —
# it propagates normally, with a full traceback, so it gets noticed and fixed
# instead of silently disappearing behind a generic error message.
⚠️ Important
Bare except: is a real code review red flag, not a style nitpick. It converts every possible failure — including bugs that have nothing to do with the operation you were actually trying to protect — into the same generic outcome, making real bugs far harder to find. Catch the specific exception types you genuinely expect and know how to handle. If you truly need to catch "anything," write except Exception: instead of bare except: — covered next, in Part 04 — which at least excludes the small set of system-level exceptions that should almost never be intercepted.
// Part 04 — The Exception Hierarchy

Exception, ValueError, KeyError, and How They Relate

Every exception in Python is a class, and exception classes form an inheritance hierarchy — a concept you will cover formally in the Object-Oriented Python phase, but the practical implication matters right now: catching a parent class also catches every one of its subclasses.

A simplified slice of the real hierarchy
BaseException
 ├── SystemExit
 ├── KeyboardInterrupt
 └── Exception                 # the class almost everything you catch inherits from
      ├── ValueError            # right type, invalid value — e.g. int("hello")
      ├── TypeError              # operation on the wrong type entirely
      ├── KeyError                # dict key that doesn't exist
      ├── IndexError                # list/sequence index out of range
      ├── AttributeError             # accessing an attribute/method that doesn't exist
      ├── FileNotFoundError            # a subclass of OSError — file doesn't exist
      └── ZeroDivisionError              # division or modulo by zero
Catching a parent class catches every subclass beneath it
try:
    value = int(user_input)
except Exception:
    # catches ValueError, TypeError, and literally any other Exception subclass —
    # broader than you usually want, but still excludes BaseException's other branches
    print("Something went wrong parsing input")

Why you almost never catch BaseException directly

Exception is itself a subclass of BaseException — but BaseException also covers KeyboardInterrupt (raised when a user presses Ctrl+C) and SystemExit (raised by sys.exit()). Catching BaseException directly means your program can no longer be interrupted or exited cleanly through the normal mechanisms — a genuinely dangerous thing to do by accident, and virtually never what you actually want. Catch Exception, or a specific subclass of it — not BaseException.

🎯 Pro Tip
Order matters when catching multiple exception types in separate blocks. Python checks except clauses top to bottom and uses the first one that matches. If you list a parent class (like Exception) before a more specific subclass (like ValueError) in separate except blocks, the specific one will never be reached — it's already caught by the broader one above it. List more specific exception types first.
// Part 05 — Raising Exceptions

raise — Signaling That Something Is Wrong, Deliberately

Exceptions aren't only something that happens to you from built-in operations — you can, and often should, raise them yourself, the moment your own code detects that something is invalid, rather than letting bad data propagate further and fail confusingly somewhere else.

Raising a built-in exception with a clear message
def set_discount(percent):
    if not 0 <= percent <= 100:
        raise ValueError(f"Discount must be between 0 and 100, got {percent}")
    return percent

set_discount(150)
# ValueError: Discount must be between 0 and 100, got 150

This is the real-world counterpart to the assert statement from the Control Flow module. assert is for catching your own programming mistakes during development, and can be globally stripped out. raise with a specific, meaningful exception type is how you validate data that genuinely matters at runtime — user input, function arguments, data read from a file or an API — and it is never optimized away.

Choose the exception type that best describes the problem

Raising the right built-in exception type — rather than always reaching for a generic Exception — lets callers catch precisely the failure category they care about, exactly as covered in Part 04.

Picking a meaningful exception type
def get_user(user_id):
    if not isinstance(user_id, int):
        raise TypeError(f"user_id must be an int, got {type(user_id).__name__}")
    user = database.lookup(user_id)
    if user is None:
        raise KeyError(f"No user found with id {user_id}")
    return user
// Part 06 — Re-raising and Chaining

Bare raise, and raise ... from ... for Exception Chaining

Sometimes you need to catch an exception — to log it, clean something up, or add context — and then let it continue propagating rather than swallowing it. A bare raise with no argument, used inside an except block, re-raises the exact same exception that was just caught, including its original traceback.

Re-raising after logging, without losing the original error
def process_payment(order):
    try:
        charge_card(order)
    except PaymentError as e:
        log_error(f"Payment failed for order {order.id}: {e}")
        raise   # re-raises the SAME PaymentError — the caller still sees it,
                # and still gets the full original traceback

raise ... from ... — deliberately chaining a new exception to its cause

Sometimes the right move isn't to re-raise the same exception, but to raise a different, more meaningful one in its place — while still preserving the original as context. raise NewError(...) from original_error does exactly this, and Python's traceback shows both, clearly labeled, rather than losing the original cause.

Wrapping a low-level exception in a more meaningful one
def load_config(path):
    try:
        with open(path, encoding="utf-8") as f:
            return json.load(f)
    except json.JSONDecodeError as e:
        raise ValueError(f"Config file {path} contains invalid JSON") from e

# The resulting traceback shows both exceptions:
#   json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
#
#   The above exception was the direct cause of the following exception:
#
#   ValueError: Config file settings.json contains invalid JSON
🎯 Pro Tip
Prefer raise ... from ... over silently catching and re-raising a plain new exception. Without from, Python still shows both exceptions in the traceback (labeled "during handling of the above exception, another exception occurred"), but from makes the causal relationship explicit and intentional, which is clearer for whoever reads the traceback later — usually you, at 2am, debugging a production incident.
// Part 07 — Custom Exceptions

Writing Your Own Exception Classes — A Banking Example

Built-in exceptions like ValueError are genuinely useful, but they describe generic problems — an invalid value, of some kind, for some reason. A custom exception class lets you describe a failure in terms specific to your own program's domain, which makes both the raising code and the catching code more expressive.

Defining a custom exception — as simple as it looks
class InsufficientFundsError(Exception):
    """Raised when a withdrawal would take an account below zero."""
    pass

class Account:
    def __init__(self, balance):
        self.balance = balance

    def withdraw(self, amount):
        if amount > self.balance:
            raise InsufficientFundsError(
                f"Cannot withdraw ${amount:.2f} — balance is only ${self.balance:.2f}"
            )
        self.balance -= amount
        return self.balance

A custom exception is just a class that inherits from Exception (directly or indirectly) — often with no additional code at all, since it primarily exists to give the error a distinct, catchable identity. The full mechanics of class definitions and inheritance are covered properly in the upcoming Object-Oriented Python phase; what matters here is that this pattern is genuinely this simple to use today.

Catching a custom exception specifically
account = Account(balance=100)

try:
    account.withdraw(250)
except InsufficientFundsError as e:
    print(f"Transaction declined: {e}")
    notify_customer(account, reason="insufficient_funds")

# Transaction declined: Cannot withdraw $250.00 — balance is only $100.00

Attaching extra data to a custom exception

A custom exception can carry more than just a message — genuinely useful when the code catching it needs structured details, not just human-readable text, to decide what to do next.

A richer custom exception, carrying structured data
class InsufficientFundsError(Exception):
    def __init__(self, requested, available):
        self.requested = requested
        self.available = available
        super().__init__(
            f"Cannot withdraw ${requested:.2f} — balance is only ${available:.2f}"
        )

try:
    account.withdraw(250)
except InsufficientFundsError as e:
    shortfall = e.requested - e.available
    print(f"Short by ${shortfall:.2f}")   # the caller can react to the SPECIFIC numbers,
                                              # not just parse the error message as text
// Part 08 — Exception Safety and with

How the with Statement Relates to Exception Handling

The Reading & Writing Files module introduced with open(...) as f: and promised this connection would be made explicit here: a context manager's guarantee — that cleanup always runs, exception or not — is built on exactly the same mechanism as finally, covered in Part 02 above.

What with open(...) is roughly equivalent to, under the hood
# This:
with open("data.txt") as f:
    process(f)

# Behaves equivalently to this, written by hand:
f = open("data.txt")
try:
    process(f)
finally:
    f.close()

# In both versions, f.close() runs whether process() succeeds,
# raises an exception, or does anything in between.

This is precisely why with is the preferred pattern for anything that needs guaranteed cleanup — not just files, but database connections, network sockets, and locks in concurrent code, all of which follow the same "acquire, use, guarantee release" shape. The full mechanics of building your own context manager — the __enter__ and __exit__ methods that make this work — are covered in depth in the Context Managers module later in this track; the important idea for now is that with is not a separate feature from exception handling, it is built on exception handling.

💡 Note
A context manager's __exit__ method is called with details about any exception that occurred inside the with block — including the option to suppress it entirely, rather than letting it propagate. This is exactly how contextlib.suppress() works, a small standard-library helper for cleanly ignoring a specific, expected exception type, which you will meet properly in the Context Managers module.
// Part 09 — Real World
💼 What This Looks Like at Work

The Bare except: That Hid a Real Bug for Three Weeks — Minneapolis, MN

Scenario — Healthcare scheduling platform, Minneapolis · Postmortem

A scheduling platform's nightly job sends appointment reminder emails to patients. For three weeks, a growing number of patients silently stopped receiving reminders — no alerts fired, no errors appeared in any dashboard, because the job itself reported success every single night.

What the postmortem finds

The code responsible — innocent-looking, and exactly the anti-pattern from Part 03
def send_reminders(appointments):
    sent = 0
    for appt in appointments:
        try:
            send_email(appt.patient_email, build_reminder(appt))
            sent += 1
        except:
            pass   # "just skip anything that fails, don't crash the whole job"
    return sent

The except: was added months earlier, deliberately, to stop one specific transient issue — an occasional email-provider timeout — from crashing the entire nightly batch over a single patient. It worked, in the narrow sense that the job never crashed again. But it also caught everything else that could possibly go wrong inside the loop, forever, with no distinction.

The real bug: a recent change to build_reminder() introduced a KeyError for a small subset of appointments missing a newly-added field. That exception was real, genuinely worth investigating — and the bare except: quietly absorbed it every night, indistinguishable from the harmless timeout it was originally written for.

The fix — catch specifically what you expect, and count what you skip
def send_reminders(appointments):
    sent, failed = 0, 0
    for appt in appointments:
        try:
            send_email(appt.patient_email, build_reminder(appt))
            sent += 1
        except EmailTimeoutError as e:
            failed += 1
            log_error(f"Timed out sending to {appt.patient_email}: {e}")
        # anything else — a KeyError from build_reminder(), a bug, anything unexpected —
        # is no longer caught here at all, and will surface immediately and loudly

    if failed:
        alert_oncall(f"{failed} reminder(s) failed to send")
    return sent

This is the precise cost of the bare except: anti-pattern described in Part 03: it doesn't just risk hiding bugs in the abstract, it genuinely did — for three weeks, in a system where a missed reminder has a real, direct impact on patients. The team's follow-up added a lint rule that flags bare except: and except Exception: without a specific reason comment, enforced in CI going forward.

// Part 10 — Misconceptions

Four Misconceptions About Exception Handling

✕ ""except: (bare, with no exception type) is a good way to make code more robust""
It makes code appear more robust while actually hiding real bugs, including ones unrelated to the failure you meant to handle — exactly what happened in the Real World example above. Catch specific exception types you genuinely expect, so unrelated bugs still surface loudly.
✕ ""The code after try: that should only run on success can just go at the end of the try block""
Putting it inside try means it is also protected by the except clauses below — if that "success" code happens to raise the same kind of exception you were catching, it gets misattributed as if the original operation had failed. else runs only after the try block completes with no exception, and is not covered by the except clauses at all.
✕ ""assert and raise are basically interchangeable ways to signal an error""
assert statements can be globally stripped out with Python's -O flag and are meant only for catching your own programming mistakes during development. raise with a specific exception type is never optimized away and is the correct tool for validating real runtime data — user input, function arguments, external data — exactly as covered in the Control Flow module's treatment of assert.
✕ ""A custom exception class needs a lot of code to be useful""
A custom exception can be a class with a single line — class InsufficientFundsError(Exception): pass — and it is already useful, because it gives that specific failure a distinct, catchable identity. Adding structured attributes (as shown in Part 07) is a genuine enhancement, but not a requirement for a custom exception to be worth defining.
// Part 11 — Interview Prep

5 Interview Questions — With Complete Answers

Explain the difference between the else and finally clauses on a try statement.
else runs only if the try block completes with no exception at all, and — unlike code placed directly inside try — it is not protected by the except clauses above it, so an exception raised inside else propagates normally rather than being caught. finally runs unconditionally, whether the try block succeeded, raised a caught exception, raised an uncaught one, or even if the except block itself raises a new exception. finally is the right place for guaranteed cleanup; else is the right place for code that should only run after confirmed success.
Why is a bare except: considered an anti-pattern, and what should you do instead?
A bare except: catches every exception, including genuine programming bugs (typos, wrong types) and system-level exceptions like KeyboardInterrupt and SystemExit — reducing all of them to the same generic outcome and making real bugs much harder to notice and diagnose. Catch specific exception types you actually expect and know how to handle; if you truly need a catch-all, use except Exception: instead, which at least excludes BaseException's system-level branches.
What is the difference between a bare raise inside an except block and raise NewError(...) from original_error?
A bare raise re-raises the exact same exception that was just caught, preserving its original traceback — used when you want to log or react to an error but still let the original propagate unchanged. raise ... from ... raises a DIFFERENT, more meaningful exception while explicitly preserving the original as its documented cause, which both appear in the resulting traceback — used when the original exception is too low-level or generic for the caller to usefully act on.
How does the with statement relate to exception handling?
A context manager's guarantee that cleanup code always runs — regardless of whether an exception occurs inside the with block — is built on the same underlying mechanism as a finally clause. with open(path) as f: is conceptually equivalent to opening the file in a try block and closing it in a finally block by hand; with statement is simply the idiomatic, less error-prone way to express that pattern.
Walk through writing and using a custom exception class for a specific business rule.
Define a class inheriting from Exception (e.g. class InsufficientFundsError(Exception): pass), optionally overriding __init__ to accept and store structured details relevant to the failure (like the requested and available amounts). Raise it with raise InsufficientFundsError(...) at the point in your code where the business rule is violated, and catch it specifically with except InsufficientFundsError as e: wherever the caller needs to react to that particular failure — giving both the raising and catching code a clear, self-documenting vocabulary instead of relying on a generic ValueError and a parsed error message.
// Common Mistakes

Exception Handling Mistakes Beginners Make Constantly

Using a bare except: (or except Exception:) to silence errors instead of fixing them
As shown in the Real World example above, this can hide genuine, unrelated bugs indefinitely, since it catches everything without distinction. Catch only the specific exception types you expect, and let anything else surface loudly.
Putting "success" logic inside try instead of else
Code placed inside try is protected by every except clause below it. If that code happens to raise the same exception type you were catching for a completely different reason, it gets misattributed. Move code that should only run after guaranteed success into an else clause.
Catching an exception and doing nothing with it (except: pass)
This is the most severe form of the bare-except problem — the failure is not logged, not counted, not surfaced anywhere. At minimum, log the exception; ideally, only catch it if you have a genuine, specific recovery action to take.
Listing a broad exception type before a more specific one in separate except blocks
except Exception: listed before except ValueError: means the ValueError block is unreachable — the broader clause above it already matches first. List more specific exception types before broader ones.
Using exceptions for routine, expected control flow instead of genuine error conditions
Wrapping every dictionary access in try/except KeyError: when .get() would do the job more clearly is a common overuse. Reserve exceptions for genuinely exceptional situations — reach for a direct .get() with a default first, when that's all you need.
// Error Library

Errors You Will Hit With Exception Handling — And Exactly Why

SyntaxError: default 'except:' must be last
Cause: A bare except: clause was written before another, more specific except clause in the same try statement. Python requires the catch-all to come last, since clauses are checked in order and a bare except: would otherwise make every clause after it unreachable.
Fix: Reorder the except clauses so specific exception types come first, and any bare except: (which, per Part 03, you should generally avoid entirely) comes last.
TypeError: catching classes that do not inherit from BaseException is not allowed
Cause: An except clause references something that is not actually an exception class — commonly a typo, or accidentally passing a variable holding a non-exception value instead of an exception type.
Fix: Confirm the class listed in except is a real exception class (built-in or a custom one inheriting from Exception), and check for a simple naming typo.
RuntimeError: No active exception to re-raise
Cause: A bare raise statement (no argument) was used outside of an except block's handling context — there is no "current" exception for it to re-raise.
Fix: Use a bare raise only inside an except block, to re-raise the exception that block just caught. If you need to raise a new exception from other code, raise a specific exception with an argument instead: raise ValueError("...").
AttributeError: 'InsufficientFundsError' object has no attribute 'requested'
Cause: A custom exception's __init__ was overridden but forgot to actually store the extra data as instance attributes (self.requested = requested) before or after calling super().__init__().
Fix: In a custom exception's __init__, explicitly assign every attribute you want callers to access later (self.requested = requested), and still call super().__init__(message) so the exception's printed message works correctly too.

🎯 Key Takeaways

  • try/except lets you catch a runtime error where it happens instead of letting it crash the whole program.
  • else runs only when the try block completes with no exception at all, and is NOT protected by the except clauses above it — the correct place for "success only" code.
  • finally always runs — exception or not, caught or not — and is the mechanism guaranteed cleanup (like with open()) is built on.
  • Never use a bare except: — it catches genuine bugs alongside the failure you meant to handle, and can hide serious problems for a long time, exactly as shown in the Real World example.
  • Exception classes form a hierarchy — catching a parent class (like Exception) also catches every subclass beneath it. List more specific exception types before broader ones.
  • Never catch BaseException directly — it includes KeyboardInterrupt and SystemExit, which should almost never be intercepted.
  • raise with a specific exception type is the correct way to validate real runtime data — unlike assert, it is never stripped out by Python's optimization flag.
  • A bare raise inside an except block re-raises the same exception; raise NewError(...) from original re-raises a different, more meaningful exception while preserving the original as its documented cause.
  • Custom exception classes — even a one-line class inheriting from Exception — give a specific failure a distinct, catchable identity, and can carry structured data beyond just a message.

What comes next

Module 18 covers how real Python projects are actually structured — the import system in full, how packages work, the if __name__ == "__main__" idiom, and building a proper requirements.txt.

Module 18 → Modules, Packages & Virtual Environments
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...