BASIC Python (Beginner Level) 📂 Basic Python Programming · 15 of 15 45 min read

Python Exception Handling

A visual, story-driven Python exception guide: the full class hierarchy from BaseException down, try / except / else / finally flow, why order matters, raising, chaining with raise from e, custom exceptions, and — critically — how to see the real error using the traceback module and logger.exception(). Learn to read tracebacks bottom-up so debugging stops being guesswork.

Section 01

Why Exceptions Exist — The Fire Alarm in Your Program

The Kitchen with No Alarms
Imagine a restaurant kitchen where nothing goes wrong ever tells anyone. The stove catches fire and the chef keeps chopping onions. A knife falls and the sous-chef keeps plating. Every disaster is discovered by the customer, at the table, with the food.

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".

🔥
The Core Insight

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.


Section 02

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
🌱
Why Only Four Classes Matter Most

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.


Section 03

The Four Base Classes — What Each One Catches

💀
BaseException
the root — never catch
The absolute ancestor. Catching this swallows KeyboardInterrupt and SystemExit, meaning users can't Ctrl+C out of your program and calls to sys.exit() get silently ignored. Reserved for framework internals.
🛡️
Exception
everything you should handle
The base class of all "normal" runtime errors. Sits just below BaseException but above every user-facing failure. If you must catch a broad category, catch this — not BaseException.
💽
OSError
files, sockets, subprocesses
Covers the outside-world failures: file not found, permission denied, network refused, timeout, disk full. Since FileNotFoundError, PermissionError, and ConnectionError all inherit from it, one except OSError catches them all.
🎯
Specific classes
precise handling
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.
👋
KeyboardInterrupt
Ctrl+C
Inherits from BaseException, not Exception. That's deliberate — it means except Exception: won't swallow it, and users can always kill your program with Ctrl+C.
🚪
SystemExit
sys.exit()
Raised when your code (or library code) calls sys.exit(). Also inherits from BaseException so a stray except Exception: doesn't turn clean shutdowns into ignored exits.
⚠️
Why 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.


Section 04

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.")
OUTPUT (user typed "twenty")
Enter your age: twenty That was not a valid number.

The Diagram — What Actually Happens

01
Python enters the try block
Execution runs statement by statement, exactly as normal.
02
Did an exception get raised?
If no — Python skips every except block and moves past the whole statement.
03
If yes — check each except in order
Python finds the first except whose class matches the raised exception (using isinstance — subclasses match too).
04
Handler runs, exception is "handled"
Execution continues after the whole try/except block. No re-raise unless you explicitly do so.
05
If no except matches
The exception keeps propagating up the call stack until something catches it — or your program dies with a traceback.
📈
Catching by Class Includes Subclasses

except OSError: matches FileNotFoundError, PermissionError, ConnectionRefusedError, and every other OSError descendant. This is why the hierarchy matters — one line covers dozens of related errors.


Section 05

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
OUTPUT
AARAV User has no name field Name is not a string No user at that position

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}")
💡
The 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.


Section 06

Order Matters — Specific Before General

The Overeager Bouncer
A nightclub has two bouncers at the door. The first one is told: "Stop anyone under 25". The second one is told: "Check IDs of anyone under 21". The line moves. A 19-year-old walks up. The first bouncer stops them and turns them away — the second bouncer never gets to do their job. The specialist was placed after the generalist, so nobody ever needed the specialist.

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.
🚫 Wrong — General Before Specific
CodeWhat happens
except OSErrorCatches everything I/O related
except FileNotFoundErrorDead code — never reached
ResultCannot handle missing files specifically
✅ Right — Specific Before General
CodeWhat happens
except FileNotFoundErrorRuns when file is missing
except OSErrorRuns for other I/O failures
ResultEach 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

Section 07

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.

🔐 The Four Clauses and When Each Runs
try
The risky code you want to guard. Runs from top to bottom until an exception (or end of block).
except
Runs only if a matching exception was raised inside try. Multiple except blocks are allowed.
else
Runs only if the try block finished without raising anything. Useful for code that should run on success but isn't part of the risky bit.
finally
Runs always — success, failure, or even 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")
OUTPUT
--- success case --- 1. opening file 2. else: parsing content 3. finally: cleanup --- failure case --- 1. opening file 2. except: file missing 3. finally: cleanup
🔑
Why Not Put Success Code in 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".


Section 08

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}")
OUTPUT
Invalid input: age must be in 0..150, got 200

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

Section 09

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")
OUTPUT
Traceback (most recent call last): File "demo.py", line 8, in load_config with open(path, encoding="utf-8") as f: FileNotFoundError: [Errno 2] No such file or directory: '/etc/app.conf' The above exception was the direct cause of the following exception: Traceback (most recent call last): File "demo.py", line 13, in <module> load_config("/etc/app.conf") File "demo.py", line 11, in load_config raise ConfigError(f"Config not available at {path}") from e ConfigError: Config not available at /etc/app.conf
SyntaxMeaningTraceback shows
raise NewError()Fresh exception, but original still visible"During handling ... another exception occurred"
raise NewError() from eDeliberate chain — new caused by old"The above ... was the direct cause"
raise NewError() from NoneSuppress the original entirelyOnly the new exception
🔍
Why Chaining Beats Swallowing

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.


Section 10

Tracebacks — Following the Trail Back to the Real Error

The Detective's Trail
A detective arriving at a crime scene doesn't just look at the body — they trace footprints backwards through the house, out the door, down the driveway, all the way to the point of entry. The most obvious evidence (the body) is often the last thing that happened; the real story is at the beginning of the trail.

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"
🔎 How to Read a Traceback in Three Steps
Step 1
Read the last line first — it names the exception class and the message. That's what went wrong.
Step 2
Read the File / line entry directly above — the file, line number, and code where the exception was raised. That's where.
Step 3
Read upward through the earlier File entries — the call chain that led there. That's how you got there.
📚
"Most Recent Call Last" — The Direction Convention

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
OUTPUT (useless — no idea WHERE it broke)
something failed: 'cost'

Section 11

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.

FunctionWhat it doesWhen 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 ---")
OUTPUT
--- caught, but let me show you the real error --- Traceback (most recent call last): File "demo.py", line 11, in <module> print("Total:", total(order)) File "demo.py", line 7, in total return sum(price_of(item) for item in order) File "demo.py", line 7, in <genexpr> return sum(price_of(item) for item in order) File "demo.py", line 4, in price_of return item["cost"] * item["qty"] KeyError: 'cost' --- carrying on ---
🏆
Now You Actually Know What Broke

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

Section 12

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.

🚫 Weak — logger.error(str(e))
What gets loggedResult
The exception's messageJust: 'cost'
Location in codeLost
Call chainLost
DebuggabilityGuess-and-check
✅ Strong — logger.exception(msg)
What gets loggedResult
Your messageHuman-readable context
Exception class + messageIncluded automatically
Full tracebackAttached automatically
DebuggabilityJump 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}])
OUTPUT
ERROR failed to process order Traceback (most recent call last): File "demo.py", line 8, in process return total(order) File "demo.py", line 3, in total return sum(price_of(item) for item in order) File "demo.py", line 3, in <genexpr> return sum(price_of(item) for item in order) File "demo.py", line 1, in price_of return item["cost"] * item["qty"] KeyError: 'cost'
🔑
Two Non-Negotiable Rules for 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__)
OUTPUT
class : KeyError message : 'cost' args : ('cost',) tb obj : <traceback object at 0x7f1a3c4d2b40> --- frames only (print_tb) --- File "demo.py", line 2, in <module> total([{"qty": 3}]) File "demo.py", line 3, in total return sum(price_of(item) for item in order) File "demo.py", line 3, in <genexpr> return sum(price_of(item) for item in order) File "demo.py", line 1, in price_of return item["cost"] * item["qty"]

Section 13

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}")
OUTPUT
Top up needed: short by 700
🏆
The Right Base Class Choice

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.


Section 14

Common Pitfalls — Anti-Patterns Every Codebase Has

💩
Bare except:
catches EVERYTHING
A bare except: also catches KeyboardInterrupt and SystemExit. Users can't Ctrl+C, sys.exit() gets swallowed, bugs go silent. Never write it.
💩
Catching Exception reflexively
hides real bugs
except Exception: is only marginally better. It buries genuine coding mistakes (TypeError, AttributeError) under a generic "something went wrong" message. Catch what you can handle.
💩
Silent except: pass
the debugger's nightmare
An empty handler discards the error with zero record. Six months later, feature X "just doesn't work" and nobody knows why. At minimum, log the exception with logger.exception() before swallowing.
💩
Printing str(e) instead of the traceback
loses location info
print(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.
💩
Wrapping too much in try
imprecise handling
A 50-line try block catches errors from operations you never intended to guard. Keep try blocks tight — just the risky call and its immediate preparation.
💩
Losing the original error
unchained re-raise
raise MyError("failed") inside an except drops the original cause. Always raise MyError("failed") from e so the traceback keeps the real story.

Section 15

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()
🏆
What Makes This Production-Grade

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.


Section 16

Golden Rules

🔥 Exception Handling — Non-Negotiable Rules
1
Never write bare 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:.
2
Catch the narrowest exception you can handle. except FileNotFoundError beats except OSError beats except Exception. Precision means each handler actually knows what to do.
3
Order matters — specific before general. A broad except placed above a narrow one makes the narrow one dead code. Python takes the first match.
4
Keep 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.
5
Use else for the success path and finally for guaranteed cleanup — closing files, releasing locks, restoring state, no matter what happened.
6
Read tracebacks bottom-up. The last line is what, the file entry above it is where, and the earlier entries are how you got there. The header says "most recent call last" for a reason.
7
Never log just 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.
8
Chain exceptions with raise X from e when translating a low-level error into a domain-specific one. Losing the original cause makes debugging dramatically harder.
9
Custom exceptions inherit from Exception, never from BaseException. Give your library a single base class with specific subclasses beneath it so users can catch broadly or narrowly.
10
Don't use exceptions for normal control flow. Raising an exception for a routine "not found" case is slow, misleading, and pollutes tracebacks. Return a sentinel or None for expected outcomes and reserve exceptions for genuine problems.
You have completed Basic Python Programming. View all sections →