Why Exceptions Exist — The Fire Alarm in Your Program
That's what a program without exception handling looks like. Errors happen — the file isn't there, the network drops, the user typed "hello" where a number belonged — and the program either crashes silently or produces nonsense results that some poor user discovers three screens later.
Exceptions are the fire alarms of your code. When something goes wrong, an alarm rings loudly, execution halts at the problem, and control jumps up the call stack looking for someone who's ready to deal with it. Your job as a programmer is to decide where to place the alarms — and who answers each one.
In Python, an exception is an object that represents an error condition. When something goes wrong, Python raises an exception. If no code catches it, the program terminates with a traceback. Exception handling is the disciplined way of saying "yes, I know this can fail — here's what I want to happen when it does".
Exceptions separate the happy path — what happens when everything works —
from the recovery paths — what to do when things break. Without them,
every function would drown in if error: return -1 checks. With them, the
main logic stays clean and error handling lives in dedicated blocks.
The Exception Hierarchy — A Family Tree of Errors
Every exception in Python is an object and every exception class inherits from a
single common ancestor: BaseException. This inheritance tree is not a trivia
question — it's the tool you use to catch broad or narrow error categories with a single
except clause.
BaseException
├── SystemExit # raised by sys.exit()
├── KeyboardInterrupt # Ctrl+C
├── GeneratorExit # generator .close()
└── Exception # <-- catch things HERE and below
├── ArithmeticError
│ ├── ZeroDivisionError
│ ├── OverflowError
│ └── FloatingPointError
├── LookupError
│ ├── IndexError # my_list[999]
│ └── KeyError # my_dict['missing']
├── OSError # file/OS problems
│ ├── FileNotFoundError
│ ├── PermissionError
│ ├── IsADirectoryError
│ ├── ConnectionError
│ │ ├── ConnectionRefusedError
│ │ ├── ConnectionResetError
│ │ └── ConnectionAbortedError
│ └── TimeoutError
├── ValueError # int('abc')
│ └── UnicodeError
│ ├── UnicodeDecodeError
│ └── UnicodeEncodeError
├── TypeError # wrong type
├── AttributeError # obj.missing_attr
├── NameError # undefined variable
│ └── UnboundLocalError
├── ImportError
│ └── ModuleNotFoundError
├── RuntimeError
│ ├── RecursionError
│ └── NotImplementedError
├── StopIteration # end of generator
└── AssertionError # failed assert
There are dozens of built-in exceptions but only four names matter for day-to-day catching:
BaseException (never catch this), Exception (catch everything you
might realistically handle), OSError (any I/O or system failure), and the
specific one you're expecting (KeyError, ValueError, etc.). The
hierarchy exists so a single except OSError can catch dozens of related
failures without listing each one.
The Four Base Classes — What Each One Catches
BaseExceptionKeyboardInterrupt and
SystemExit, meaning users can't Ctrl+C out of your program and calls to
sys.exit() get silently ignored. Reserved for framework internals.
ExceptionBaseException but above every user-facing failure. If you must catch a
broad category, catch this — not BaseException.
OSErrorFileNotFoundError, PermissionError,
and ConnectionError all inherit from it, one except OSError
catches them all.
ValueError, KeyError, TypeError,
ZeroDivisionError, etc. Catch these when you know exactly what can go
wrong and how to recover. The narrower the catch, the safer the code.
KeyboardInterruptBaseException, not Exception. That's deliberate —
it means except Exception: won't swallow it, and users can always kill your
program with Ctrl+C.
SystemExitsys.exit(). Also inherits
from BaseException so a stray except Exception: doesn't turn
clean shutdowns into ignored exits.
KeyboardInterrupt Doesn't Inherit from Exception
This is one of the smartest design decisions in Python. If KeyboardInterrupt
were an Exception, then every except Exception: in the wild
would swallow Ctrl+C, and users would have to open Task Manager to kill runaway scripts.
By placing it directly under BaseException, Python guarantees that broad
handlers still let the user out.
The try / except Statement — Basic Anatomy
The simplest exception handler has two parts: a try block
where the risky code lives, and an except block that runs
only if a matching exception is raised inside try.
try:
# The "maybe this will fail" code
age = int(input("Enter your age: "))
print(f"You are {age} years old.")
except ValueError:
# Runs only if a ValueError was raised inside try
print("That was not a valid number.")
The Diagram — What Actually Happens
try blockexcept block and moves past the whole statement.except in orderexcept whose class matches the raised exception (using isinstance — subclasses match too).try/except block. No re-raise unless you explicitly do so.except matches
except OSError: matches FileNotFoundError,
PermissionError, ConnectionRefusedError, and every other
OSError descendant. This is why the hierarchy matters — one line covers
dozens of related errors.
Multiple except Blocks — Different Handling for Different Errors
A single try can be followed by many except blocks. Python checks
them top to bottom and runs the first one that matches. This is like a
cascading if / elif for errors.
def get_user(users, index):
try:
user = users[index]
return user["name"].upper()
except IndexError:
return "No user at that position"
except KeyError:
return "User has no name field"
except AttributeError:
return "Name is not a string"
users = [
{"name": "Aarav"},
{"email": "p@ex.com"}, # missing 'name'
{"name": 42}, # not a string
]
print(get_user(users, 0)) # happy path
print(get_user(users, 1)) # KeyError
print(get_user(users, 2)) # AttributeError
print(get_user(users, 99)) # IndexError
Catching Multiple Types in One Block
If different exceptions should get the same treatment, list them as a tuple:
try:
result = risky_operation()
except (ValueError, TypeError, KeyError) as e:
print(f"Input problem: {e}")
except OSError as e:
print(f"System problem: {e}")
as e Clause
except SomeError as e: binds the exception object to the name e
inside the block. Now you can inspect e.args, log str(e), or
use e.__class__.__name__. Without as, you handle the error but
lose all detail about it — often not what you want.
Order Matters — Specific Before General
This is exactly what happens if you put a broad
except before a narrow one.
Python takes the first match and never checks the rest.
| Code | What happens |
|---|---|
except OSError | Catches everything I/O related |
except FileNotFoundError | Dead code — never reached |
| Result | Cannot handle missing files specifically |
| Code | What happens |
|---|---|
except FileNotFoundError | Runs when file is missing |
except OSError | Runs for other I/O failures |
| Result | Each error gets its own tailored response |
# The correct ordering: narrow to broad
try:
with open("data.txt", encoding="utf-8") as f:
content = f.read()
except FileNotFoundError:
print("File does not exist — creating a fresh one")
content = ""
except PermissionError:
print("No permission — asking user for another location")
raise
except OSError as e:
print(f"Some other I/O problem: {e}")
raise
The Full Statement — try / except / else / finally
Python's full exception statement has four clauses. Most code uses only try
and except, but else and finally exist for a reason.
try. Multiple except blocks are allowed.
try block finished without raising anything. Useful for code that should run on success but isn't part of the risky bit.
return mid-block. Used for cleanup (closing connections, releasing locks) that must happen no matter what.
Diagram — Execution Flow
┌────────────────────────────────┐
│ Enter try block │
└──────────────┬─────────────────┘
│
┌───────┴────────┐
│ │
No exception Exception raised
│ │
▼ ▼
┌─────────────┐ ┌──────────────────┐
│ else block │ │ except matches? │
│ runs (if │ │ ─ yes → run it │
│ present) │ │ ─ no → propagate│
└──────┬──────┘ └────────┬──────────┘
│ │
└────────┬─────────┘
▼
┌───────────────┐
│ finally runs │ <- ALWAYS, even on return
└───────┬───────┘
▼
Continue after block
(or propagate exception)
All Four Clauses in Action
def read_config(path):
try:
print("1. opening file")
f = open(path, encoding="utf-8")
content = f.read()
except FileNotFoundError:
print("2. except: file missing")
return None
else:
print("2. else: parsing content")
return content.strip().splitlines()
finally:
print("3. finally: cleanup")
try:
f.close()
except NameError:
pass # f was never assigned
print("--- success case ---")
read_config("settings.txt")
print("--- failure case ---")
read_config("missing.txt")
try?
You could put the parsing inside the try block, but then an unrelated
ValueError from parsing would be caught by any broad handler you added
later. The else clause makes intent explicit: "only these two lines are
the risky I/O — everything else runs only if that succeeded, and its errors should
propagate normally".
Raising Exceptions — Setting Off Your Own Alarms
You don't just catch exceptions — you raise them too. When your function detects something wrong that it can't reasonably fix, it should raise a clear, specific exception so the caller can decide what to do.
def divide(a, b):
if b == 0:
raise ZeroDivisionError("cannot divide by zero")
return a / b
def set_age(age):
if not isinstance(age, int):
raise TypeError(f"age must be int, got {type(age).__name__}")
if age < 0 or age > 150:
raise ValueError(f"age must be in 0..150, got {age}")
return age
# Callers can choose to catch — or let the error bubble up
try:
set_age(200)
except ValueError as e:
print(f"Invalid input: {e}")
Re-raising — Catch, Log, Rethrow
Sometimes you want to note that an error happened but let it keep propagating.
A bare raise inside an except block re-raises the current exception
unchanged.
try:
result = risky_call()
except ConnectionError as e:
logger.warning(f"Connection failed: {e} — will retry higher up")
raise # same exception continues bubbling
Exception Chaining — raise X from Y
When a low-level exception causes a higher-level one, you want to preserve the whole chain
so debugging shows the full story. Python's raise ... from ... syntax makes
this explicit.
class ConfigError(Exception):
"""Raised when configuration cannot be loaded."""
def load_config(path):
try:
with open(path, encoding="utf-8") as f:
return f.read()
except FileNotFoundError as e:
# Chain: keep the original in the traceback
raise ConfigError(f"Config not available at {path}") from e
load_config("/etc/app.conf")
| Syntax | Meaning | Traceback shows |
|---|---|---|
raise NewError() | Fresh exception, but original still visible | "During handling ... another exception occurred" |
raise NewError() from e | Deliberate chain — new caused by old | "The above ... was the direct cause" |
raise NewError() from None | Suppress the original entirely | Only the new exception |
Turning a FileNotFoundError into a domain-specific ConfigError
is great API design — but if you drop the original, you lose the actual file path from
the OS-level error. raise ... from e keeps both, so future-you (or a
teammate at 3 AM) can see exactly which file was missing.
Tracebacks — Following the Trail Back to the Real Error
A Python traceback is exactly that trail. When an exception is raised, Python records every function call that led there, then prints them in order — from the entry point at the top to the actual crime scene at the bottom. Learning to read this trail is the single most important debugging skill in Python.
Anatomy of a Traceback — Read It Bottom-Up
Traceback (most recent call last): <- header
File "main.py", line 25, in <module> | 1. entry point
total = calculate_total(orders) | where it all started
File "main.py", line 12, in calculate_total | 2. next call down
return sum(price_of(item) for item in row) |
File "main.py", line 6, in price_of | 3. THE CRIME SCENE
return item["cost"] * item["qty"] | where it actually broke
KeyError: 'cost' <- the "what"
The header "most recent call last" tells you the traceback is printed chronologically — top is where execution began, bottom is where it ended in flames. This trips up beginners who instinctively read top-down looking for the error. Train yourself to jump to the last line first.
The Problem — Catching Silences the Traceback
When Python catches an exception, the traceback disappears from your terminal — because that's your job to handle now. But that's often exactly when you want to see it, for logging or debugging.
def process(data):
try:
return compute(data)
except Exception as e:
print(f"something failed: {e}") # USELESS — just says the message
return None
The traceback Module — Seeing the Real Error
Python's built-in traceback module lets you print or capture the full traceback
even after you've caught the exception. This is the difference between a useless
"something failed" log and a stack trace pointing at the exact line.
| Function | What it does | When to use |
|---|---|---|
traceback.print_exc() |
Prints the current exception's full traceback to stderr | Quick debugging inside except |
traceback.format_exc() |
Returns the traceback as a str |
Save to file, log, send in email |
traceback.print_tb(tb) |
Prints just the call chain (no exception line) | When you already have the exception separately |
traceback.format_exception(e) |
Returns the traceback as a list of strings | Custom formatting or joining |
Example — print_exc(): See What You Caught
import traceback
def price_of(item):
return item["cost"] * item["qty"]
def total(order):
return sum(price_of(item) for item in order)
order = [{"cost": 10, "qty": 2}, {"qty": 3}] # second item missing 'cost'
try:
print("Total:", total(order))
except Exception:
print("--- caught, but let me show you the real error ---")
traceback.print_exc()
print("--- carrying on ---")
One line — traceback.print_exc() — turned the useless
"something failed: 'cost'" into the full call chain: <module>
→ total → generator → price_of, ending on the exact line
return item["cost"] * item["qty"]. That's the difference between guessing
and knowing.
Example — format_exc(): Send It to a File
print_exc writes to stderr, which is fine for terminals but useless for
services. format_exc returns the same traceback as a string — perfect for
writing to a log file or an error report.
import traceback, datetime
from pathlib import Path
def safe_run(func, *args):
try:
return func(*args)
except Exception:
tb_text = traceback.format_exc() # full traceback as a string
now = datetime.datetime.now().isoformat(timespec="seconds")
with open("errors.log", "a", encoding="utf-8") as f:
f.write(f"\n===== {now} =====\n")
f.write(tb_text)
print("An error occurred. Details written to errors.log")
return None
The Production Pattern — logging.exception()
In real applications you almost never call traceback.print_exc() directly.
Instead you use Python's logging module, which has a shortcut method purpose-built
for logging exceptions with their full traceback attached.
logger.error(str(e))| What gets logged | Result |
|---|---|
| The exception's message | Just: 'cost' |
| Location in code | Lost |
| Call chain | Lost |
| Debuggability | Guess-and-check |
logger.exception(msg)| What gets logged | Result |
|---|---|
| Your message | Human-readable context |
| Exception class + message | Included automatically |
| Full traceback | Attached automatically |
| Debuggability | Jump straight to the line |
import logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger(__name__)
def process(order):
try:
return total(order)
except Exception:
logger.exception("failed to process order") # msg + traceback
return None
process([{"cost": 10, "qty": 2}, {"qty": 3}])
logger.exception
(1) Only call it inside an except block — outside, there's
no current exception and it fails to attach a traceback.
(2) Pass a context message, not the exception itself. The traceback carries the
exception; your message adds the "why we were doing this" that the traceback can't know.
Accessing a Traceback Object Directly — e.__traceback__
Every caught exception has a __traceback__ attribute pointing at the traceback
object. You can pass it to traceback.print_tb() or store it for later — useful
if you want to save an exception now and report it after the block has exited.
import traceback
try:
total([{"qty": 3}])
except Exception as e:
print("class :", type(e).__name__)
print("message :", e)
print("args :", e.args)
print("tb obj :", e.__traceback__)
print("--- frames only (print_tb) ---")
traceback.print_tb(e.__traceback__)
Custom Exceptions — Your Own Alarm Classes
Built-in exceptions cover generic problems. Domain-specific problems deserve domain-specific
exception classes. Custom exceptions are just Python classes that inherit from
Exception (or one of its subclasses).
# A small hierarchy for a payment library
class PaymentError(Exception):
"""Base class for all payment-related failures."""
class InsufficientFundsError(PaymentError):
def __init__(self, balance, amount):
super().__init__(f"Need {amount}, only {balance} available")
self.balance = balance
self.amount = amount
class CardDeclinedError(PaymentError):
def __init__(self, reason):
super().__init__(f"Card declined: {reason}")
self.reason = reason
def charge(balance, amount):
if amount > balance:
raise InsufficientFundsError(balance, amount)
if amount > 10000:
raise CardDeclinedError("amount exceeds daily limit")
return balance - amount
# Callers can catch broad or narrow
try:
charge(balance=500, amount=1200)
except InsufficientFundsError as e:
print(f"Top up needed: short by {e.amount - e.balance}")
except PaymentError as e:
print(f"Some other payment issue: {e}")
Inherit custom exceptions from Exception — not BaseException.
For a library, define one base class (PaymentError) and put every specific
error under it. Users can then catch the whole family with a single
except PaymentError: or pick a specific subclass.
Common Pitfalls — Anti-Patterns Every Codebase Has
except:except: also catches KeyboardInterrupt and
SystemExit. Users can't Ctrl+C, sys.exit() gets swallowed,
bugs go silent. Never write it.
Exception reflexivelyexcept Exception: is only marginally better. It buries genuine coding
mistakes (TypeError, AttributeError) under a generic
"something went wrong" message. Catch what you can handle.
except: passlogger.exception() before swallowing.
str(e) instead of the tracebackprint(f"failed: {e}") tells you the message but not where. Use
traceback.print_exc() or logger.exception() to keep the file,
line, and call chain.
trytry block catches errors from operations you never intended to
guard. Keep try blocks tight — just the risky call and its immediate
preparation.
raise MyError("failed") inside an except drops the original
cause. Always raise MyError("failed") from e so the traceback keeps the
real story.
A Complete Real-World Example — A Robust API Client
This function fetches user data from a URL, converts it, and saves it to disk. It uses
every technique in this tutorial: specific-before-general catches, else for the
success path, finally for cleanup, custom exceptions, chaining to preserve
the original error, and logger.exception() so tracebacks land in the log file
where you can actually debug them.
import json, logging
from pathlib import Path
logger = logging.getLogger(__name__)
class ApiClientError(Exception):
"""Base for all API client errors."""
class UserNotFound(ApiClientError):
"""The requested user does not exist."""
class MalformedResponse(ApiClientError):
"""Server returned something we cannot parse."""
def save_user_profile(user_id, out_path):
conn = None
try:
conn = open_connection() # pretend network call
raw = conn.fetch(f"/users/{user_id}")
if raw is None:
raise UserNotFound(f"No user with id {user_id}")
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
raise MalformedResponse("bad JSON from server") from e
except ConnectionError:
logger.exception("network unreachable") # traceback into log
return False
except UserNotFound as e:
logger.warning(str(e)) # expected, no traceback needed
return False
except ApiClientError:
logger.exception("unexpected API error") # traceback + re-raise
raise
else:
# Runs only if the try block succeeded
try:
Path(out_path).write_text(
json.dumps(data, indent=2),
encoding="utf-8",
)
logger.info(f"saved profile to {out_path}")
return True
except OSError as e:
raise ApiClientError(f"could not save profile: {e}") from e
finally:
# Always release the connection, success or failure
if conn is not None:
conn.close()
Specific errors are caught specifically. The inner try/except around
json.loads chains its low-level error into a domain error with
from e. The success path lives in else. finally
guarantees the connection closes. And every unexpected error path calls
logger.exception(), so when things break at 3 AM, the log has
the full traceback pointing at the exact line — not a mystery message.
Golden Rules
except:. It catches
KeyboardInterrupt and SystemExit, breaking Ctrl+C and clean
shutdowns. If you truly need to catch everything you can recover from, use
except Exception:.
except FileNotFoundError beats except OSError beats
except Exception. Precision means each handler actually knows what to do.
except
placed above a narrow one makes the narrow one dead code. Python takes the first match.
try blocks tight. Guard the single risky operation,
not fifty lines of business logic. Wide try blocks catch errors from
operations you didn't mean to guard and hide the source of the failure.
else for the success path and finally
for guaranteed cleanup — closing files, releasing locks, restoring state, no matter what
happened.
str(e). Use logger.exception(msg)
inside except blocks — you get the exception class, message, and the
full traceback pointing at the exact line, all for free.
raise X from e when translating a
low-level error into a domain-specific one. Losing the original cause makes debugging
dramatically harder.
Exception, never from
BaseException. Give your library a single base class with specific subclasses
beneath it so users can catch broadly or narrowly.
None for expected outcomes and reserve exceptions for genuine
problems.