Mid-level 7 min · March 05, 2026

Python Exception Handling Explained — try, except, finally and Real-World Patterns

Python exception handling demystified: learn try, except, else, finally with real-world examples, common mistakes, and patterns senior devs actually use..

N
Naren Founder & Principal Engineer

20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.

Follow
Production
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Try/except guards risky operations; except blocks catch specific exception types by matching class hierarchy.
  • else runs only on success; finally always runs for cleanup — even after return or exception.
  • Custom exceptions inherit from Exception and carry structured data like error codes and retry flags.
  • Bare except: catches BaseException including KeyboardInterrupt — always use except Exception unless you have a reason.
  • Caught exception overhead is ~0.5 µs in CPython; raising is cheap but exception objects are heap-allocated so don't use exceptions for normal flow.
✦ Definition~90s read
What is Exception Handling in Python?

Python exception handling is the mechanism for responding to runtime errors without crashing your program. It exists because production code encounters the unexpected—network timeouts, missing files, malformed data—and you need to decide how to recover, retry, or fail gracefully.

Imagine you're following a recipe and step 4 says 'add eggs' — but you open the fridge and there are no eggs.

The try/except/finally block lets you intercept exceptions at specific points, execute cleanup logic regardless of outcome, and propagate errors up the call stack when you can't handle them locally. Under the hood, Python's interpreter maintains a per-thread exception stack; when an exception is raised, it unwinds frames until it finds a matching except clause or reaches the top-level handler, which terminates the process by default.

In the ecosystem, exception handling competes with error-return-value patterns (like Go's (result, error) tuples) and monadic approaches (like Rust's Result type). Python chose exceptions because they're non-invasive—you don't pollute every function signature with error types—but this freedom requires discipline.

The senior developer pattern is to catch exceptions at the right abstraction boundary: low-level code raises specific exceptions (e.g., ConnectionTimeout), middleware translates them into domain exceptions (e.g., PaymentGatewayDown), and top-level handlers log and return HTTP 500s or user-facing error messages. Context managers (with blocks) and raise ... from for exception chaining are the tools that keep tracebacks readable and resource leaks impossible.

The critical anti-pattern is catching Exception (or worse, bare except:) at the wrong level. This swallows KeyboardInterrupt, SystemExit, and bugs you need to see. The rule: only catch exceptions you can actually handle—retry transient failures, log and re-raise everything else.

In async code, unhandled exceptions in tasks are silently dropped unless you explicitly await or gather() with return_exceptions=True. Senior engineers watch for this because a background task that silently dies can corrupt state or leave resources locked for hours.

The real power of Python's exception system isn't preventing crashes—it's making crashes predictable, auditable, and recoverable.

Plain-English First

Imagine you're following a recipe and step 4 says 'add eggs' — but you open the fridge and there are no eggs. Without a backup plan, you'd just stand there frozen. Exception handling is your backup plan: it says 'if step 4 fails, do THIS instead, then keep going.' Python uses the same idea — when something goes wrong at runtime, you catch the problem, handle it gracefully, and prevent your whole program from crashing.

Every program that talks to the outside world — reading files, calling APIs, querying databases — is making a bet that things will go smoothly. Spoiler: they won't. Files get deleted, networks drop, users type letters where numbers belong. Without a strategy for these moments, your program crashes and leaves users staring at a traceback they don't understand. Exception handling is how professional Python code stays alive under pressure.

The problem isn't that errors happen — it's that unhandled errors are brutal. They expose implementation details, lose in-progress work, and destroy user trust. Python's exception system gives you a structured way to anticipate failure, respond intelligently, and clean up after yourself no matter what happened. The difference between amateur and professional Python code is often just how gracefully it fails.

By the end of this article you'll know exactly how try/except/else/finally fit together, how to write custom exceptions for your own projects, when to catch broadly vs. narrowly, and the real-world patterns that show up in production codebases. You'll also know the mistakes that trip up 80% of intermediate developers — so you can skip them entirely.

What Python Exception Handling Actually Does

Exception handling in Python is a structured mechanism to intercept and respond to runtime errors without crashing the process. The core mechanic is the try block, which marks code that may raise an exception, paired with except blocks that catch specific exception types. When an exception occurs, Python unwinds the call stack until it finds a matching except handler; if none exists, the interpreter terminates with a traceback.

In practice, try/except works like a conditional branch for error paths. You can chain multiple except clauses for different exception types (e.g., ValueError, KeyError), and the first matching handler executes. The finally block runs unconditionally — even if an exception is raised or a return statement is hit — making it the correct place for cleanup like closing file handles or releasing locks. Python’s exception hierarchy (BaseException → Exception → specific types) lets you catch broadly or narrowly.

Use exception handling when an operation can fail in ways you can anticipate and recover from: parsing user input, network calls, file I/O. It is not for flow control — avoid catching generic Exception to hide bugs. In production systems, unhandled exceptions crash workers (e.g., a Gunicorn worker dies, dropping in-flight requests), while overly broad catches mask logic errors that silently corrupt data.

Don't Catch Everything
Catching bare except: or Exception swallows KeyboardInterrupt and SystemExit, making your process unkillable. Always specify the exception type.
Production Insight
A payment processing service caught Exception broadly in its retry loop, masking a KeyError from a missing config field. The service silently retried 50 times per transaction, exhausting the database connection pool and causing a 15-minute outage during peak load. Rule: catch only the exceptions you can handle; let unexpected ones propagate to a top-level logger and alert.
Key Takeaway
Use specific exception types in except clauses — never bare except.
Always put resource cleanup in finally, not after the try block.
Let unexpected exceptions crash fast and alert, don't swallow them silently.
Python Exception Handling Flow THECODEFORGE.IO Python Exception Handling Flow From try/except to async patterns and logging try/except Block Catches exceptions; else runs on success Custom Exceptions Inherit from Exception for clarity Context Managers Use with for resource cleanup Async Exception Handling Catch in coroutines to avoid silent fails Logging with Traceback Use logging.exception for full context ⚠ Bare except catches all, hides bugs Always specify exception type or use except Exception THECODEFORGE.IO
thecodeforge.io
Python Exception Handling Flow
Exception Handling Python

How Python's try/except Block Actually Works Under the Hood

When Python enters a try block, it doesn't just cross its fingers — it sets up a small safety net. If any line inside that block raises an exception, Python immediately stops executing the rest of the try block and jumps to the matching except clause. If no exception is raised, the except clause is skipped entirely.

The key word is 'matching.' Python checks each except clause top-to-bottom, looking for a clause whose exception type matches the raised exception (or is a parent class of it). This means ORDER MATTERS. If you catch a broad exception like Exception before a narrow one like ValueError, the narrow one will never be reached.

The else clause is the hidden gem most developers ignore: it runs only when the try block completed without raising any exception. This lets you separate 'the risky operation' from 'what to do with a successful result' — a pattern that makes code dramatically easier to read. Think of it as: try = attempt the danger zone, except = handle the mess, else = celebrate the win, finally = clean up regardless.

file_reader.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
def read_config_file(file_path: str) -> dict:
    """
    Reads a JSON config file and returns its contents as a dictionary.
    Demonstrates try / except / else / finally working together.
    """
    config_file = None

    try:
        # ATTEMPT: open the file — this can raise FileNotFoundError
        config_file = open(file_path, 'r')

        import json
        # ATTEMPT: parse JSON — this can raise json.JSONDecodeError
        config_data = json.load(config_file)

    except FileNotFoundError:
        # Fires ONLY when the file doesn't exist at that path
        print(f"[ERROR] Config file not found at: {file_path}")
        return {}  # Return a safe default instead of crashing

    except json.JSONDecodeError as parse_error:
        # Fires ONLY when the file exists but contains invalid JSON
        # 'as parse_error' gives us access to the error details
        print(f"[ERROR] Config file has invalid JSON: {parse_error.msg}")
        return {}

    else:
        # Runs ONLY if the try block succeeded with zero exceptions
        # Perfect place for 'success path' logic — keeps it separate from error handling
        print(f"[OK] Config loaded successfully. Keys found: {list(config_data.keys())}")
        return config_data

    finally:
        # Runs ALWAYS — whether an exception happened or not
        # Critical for cleanup: close the file so we don't leak file handles
        if config_file and not config_file.closed:
            config_file.close()
            print("[CLEANUP] File handle closed.")


# --- Test case 1: file exists and is valid JSON ---
# Assume 'settings.json' contains: {"debug": true, "port": 8080}
result = read_config_file("settings.json")
print(f"Result: {result}\n")

# --- Test case 2: file does not exist ---
result = read_config_file("missing.json")
print(f"Result: {result}")
Output
[OK] Config loaded successfully. Keys found: ['debug', 'port']
[CLEANUP] File handle closed.
Result: {'debug': True, 'port': 8080}
[ERROR] Config file not found at: missing.json
Result: {}
Pro Tip: Use 'else' to Signal Success Explicitly
The else clause after try/except is underused and underrated. Putting success-path code inside the try block itself makes it ambiguous — is this line part of the risky operation or the happy path? Move post-success logic into else and your intent becomes crystal clear.
Production Insight
In production, putting too much code inside try makes debugging harder.
If 10 lines can fail, you won't know which broke when you see the exception.
Rule: keep try to the 1-2 statements that actually raise; move everything else to else.
Key Takeaway
The order of except clauses determines which handler fires.
Put specific exceptions before generic ones, or they'll never execute.
Use else to keep success logic separate — it's a readability power move.

Building Custom Exceptions That Actually Communicate Intent

Python's built-in exceptions are great for generic problems, but they're terrible communicators for domain-specific failures. When your payment service fails, raising a generic ValueError tells the caller almost nothing. A PaymentDeclinedError with a reason code and retry hint — that's information a caller can act on.

Custom exceptions are just classes that inherit from Exception (or a more specific built-in). The real power comes from adding attributes: error codes, context data, user-facing messages, or flags like is_retryable. This turns exceptions from blunt instruments into structured data packets.

The best pattern is an exception hierarchy: a base exception for your module, then specific exceptions that inherit from it. This lets callers choose their level of granularity — catch the base exception to handle everything from your module, or catch a specific subclass to handle only one scenario. This is exactly how Python's own standard library works: OSError is the parent of FileNotFoundError, PermissionError, and a dozen others.

payment_exceptions.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# --- Define a custom exception hierarchy for a payment module ---

class PaymentError(Exception):
    """
    Base exception for all payment-related failures.
    Any caller catching PaymentError will catch all subclasses too.
    """
    def __init__(self, message: str, transaction_id: str):
        # Always call super().__init__() so Python's exception machinery works correctly
        super().__init__(message)
        self.transaction_id = transaction_id  # Attach useful context data


class PaymentDeclinedError(PaymentError):
    """
    Raised when the card issuer declines the charge.
    Carries a decline_code so the caller knows WHYnot just that it failed.
    """
    def __init__(self, transaction_id: str, decline_code: str):
        message = f"Payment declined (code: {decline_code})"
        super().__init__(message, transaction_id)
        self.decline_code = decline_code
        self.is_retryable = False  # Declined cards won't succeed on retry


class PaymentGatewayTimeoutError(PaymentError):
    """
    Raised when the payment gateway doesn't respond in time.
    Unlike a decline, this one IS safe to retry.
    """
    def __init__(self, transaction_id: str, timeout_seconds: int):
        message = f"Gateway timed out after {timeout_seconds}s"
        super().__init__(message, transaction_id)
        self.is_retryable = True  # Timeout might be temporary — caller can retry


# --- Simulate a payment processing function ---

def process_payment(amount: float, card_token: str, transaction_id: str) -> str:
    """
    Attempts to charge a card. Raises specific PaymentError subclasses on failure.
    """
    # Simulate a declined card scenario
    if card_token == "DECLINED_CARD":
        raise PaymentDeclinedError(
            transaction_id=transaction_id,
            decline_code="insufficient_funds"
        )

    # Simulate a gateway timeout
    if card_token == "SLOW_GATEWAY":
        raise PaymentGatewayTimeoutError(
            transaction_id=transaction_id,
            timeout_seconds=30
        )

    return f"Success: charged ${amount:.2f}"


# --- Caller code: handle each failure type differently ---

def checkout(amount: float, card_token: str):
    transaction_id = "TXN-20240815-001"

    try:
        result = process_payment(amount, card_token, transaction_id)
        print(f"[PAYMENT] {result}")

    except PaymentDeclinedError as error:
        # We know exactly why it failed AND that retrying is pointless
        print(f"[DECLINED] Transaction {error.transaction_id} failed: {error}")
        print(f"  Decline reason: {error.decline_code}")
        print(f"  Retryable: {error.is_retryable}")
        # In real code: show user a 'check your card details' message

    except PaymentGatewayTimeoutError as error:
        # Different response — we might queue this for automatic retry
        print(f"[TIMEOUT] Transaction {error.transaction_id} timed out: {error}")
        print(f"  Retryable: {error.is_retryable}")
        # In real code: push transaction_id onto a retry queue

    except PaymentError as error:
        # Catch-all for any other payment failure we didn't specifically handle
        print(f"[PAYMENT ERROR] Unexpected failure for {error.transaction_id}: {error}")


print("=== Test 1: Declined card ===")
checkout(99.99, "DECLINED_CARD")

print("\n=== Test 2: Gateway timeout ===")
checkout(49.99, "SLOW_GATEWAY")

print("\n=== Test 3: Success ===")
checkout(25.00, "VALID_TOKEN")
Output
=== Test 1: Declined card ===
[DECLINED] Transaction TXN-20240815-001 failed: Payment declined (code: insufficient_funds)
Decline reason: insufficient_funds
Retryable: False
=== Test 2: Gateway timeout ===
[TIMEOUT] Transaction TXN-20240815-001 timed out: Gateway timed out after 30s
Retryable: True
=== Test 3: Success ===
[PAYMENT] Success: charged $25.00
Interview Gold: Why Inherit from Exception, Not BaseException?
BaseException is the root of ALL Python exceptions, including KeyboardInterrupt and SystemExit — signals that should almost never be caught by application code. Inheriting from Exception keeps your custom exceptions out of that low-level signal territory, so a bare 'except Exception' won't accidentally swallow a Ctrl+C.
Production Insight
In production, a PaymentDeclinedError without a decline_code leaves your frontend guessing.
Without an is_retryable flag, you might retry a declined card indefinitely — incurring processor fees.
Rule: every custom exception should carry the data a caller needs to decide what to do next.
Key Takeaway
Custom exceptions are data packets, not just messages.
Build a hierarchy with a base exception per domain.
Add attributes like is_retryable and error codes — they turn exceptions into actionable signals.

Context Managers and Exception Chaining — The Senior Developer Patterns

Two patterns separate intermediate exception handling from genuinely professional code: context managers and exception chaining.

Context managers (the with statement) are the correct way to handle any resource that needs cleanup — files, database connections, network sockets, locks. Under the hood, Python calls __exit__ on the context manager even if an exception blows up inside the with block. This replaces the try/finally pattern for resource cleanup and removes the risk of forgetting to close something.

Exception chaining solves a subtle but serious problem: what happens when your exception handler itself fails, or when you want to raise a higher-level exception but preserve the original cause? Python's 'raise NewError() from original_error' syntax chains the exceptions together, so the traceback shows both the root cause and the higher-level consequence. Without this, you lose the original traceback — and debugging becomes a nightmare. Critically, 'raise NewError() from None' explicitly suppresses the chain when the original exception would confuse rather than help the caller.

database_service.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
import sqlite3
from contextlib import contextmanager

# --- Custom exception for our data layer ---

class DatabaseError(Exception):
    """Raised when a database operation fails at the application level."""
    pass


# --- Context manager for safe database transactions ---

@contextmanager
def managed_transaction(db_path: str):
    """
    A context manager that handles the full lifecycle of a database connection:
    - Opens the connection
    - Commits if the block succeeds
    - Rolls back if any exception occurs
    - Always closes the connection

    Usage:
        with managed_transaction('mydb.sqlite') as cursor:
            cursor.execute(...)
    """
    connection = sqlite3.connect(db_path)
    cursor = connection.cursor()

    try:
        # Yield the cursor to the 'with' block — execution pauses here
        yield cursor

        # If we reach this line, the 'with' block completed without exceptions
        connection.commit()
        print("[DB] Transaction committed.")

    except Exception as db_exception:
        # Something went wrong inside the 'with' block — roll back every change
        connection.rollback()
        print(f"[DB] Transaction rolled back due to: {db_exception}")

        # EXCEPTION CHAINING: wrap the low-level sqlite error in our domain error.
        # 'raise ... from db_exception' preserves the original traceback as __cause__.
        # Callers see a clean DatabaseError, but the full sqlite context is still there
        # for debugging when they inspect the traceback.
        raise DatabaseError(
            f"Failed to complete database operation: {db_exception}"
        ) from db_exception

    finally:
        # Runs regardless — closes connection even if commit or rollback failed
        connection.close()
        print("[DB] Connection closed.")


# --- Application-level function using the context manager ---

def save_user(db_path: str, username: str, email: str) -> None:
    """
    Saves a new user record. If anything fails, the whole transaction rolls back.
    """
    try:
        with managed_transaction(db_path) as cursor:
            # Create table if needed (idempotent)
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS users (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    username TEXT UNIQUE NOT NULL,
                    email TEXT NOT NULL
                )
            """)

            # This INSERT will fail if username already exists (UNIQUE constraint)
            cursor.execute(
                "INSERT INTO users (username, email) VALUES (?, ?)",
                (username, email)
            )
            print(f"[APP] User '{username}' queued for insert.")

    except DatabaseError as error:
        # We catch our domain-level error here
        print(f"[APP] Could not save user: {error}")
        # The original sqlite3 error is still accessible at error.__cause__
        print(f"[APP] Root cause: {error.__cause__}")


# --- Test it ---
DB_FILE = ":memory:"  # In-memory SQLite — no file needed, perfect for demos

print("=== Insert first user ===")
save_user(DB_FILE, "alice", "alice@example.com")
# Note: :memory: databases are fresh per-connection, so duplicate test
# needs a persistent file. We'll simulate a constraint violation differently.

print("\n=== Simulate a broken operation ===")
try:
    with managed_transaction(DB_FILE) as cursor:
        cursor.execute("SELECT * FROM nonexistent_table")  # This will fail
except DatabaseError as error:
    print(f"[APP] Caught at top level: {error}")
Output
=== Insert first user ===
[APP] User 'alice' queued for insert.
[DB] Transaction committed.
[DB] Connection closed.
=== Simulate a broken operation ===
[DB] Transaction rolled back due to: no such table: nonexistent_table
[DB] Connection closed.
[APP] Caught at top level: Failed to complete database operation: no such table: nonexistent_table
Watch Out: raise without 'from' Hides Your Root Cause
If you catch an exception and raise a new one without 'from', Python still chains them implicitly (as __context__, not __cause__), but the traceback message says 'During handling of the above exception, another exception occurred' — which is confusing. Always use 'raise NewError() from original' to make the chain intentional and explicit.
Production Insight
Production debugging often starts at a DatabaseError but needs to see the underlying sqlite3 error.
Without from, you lose that link and spend hours guessing what actually broke.
Rule: always chain when wrapping an exception — it's the difference between a 5-minute fix and a 5-hour investigation.
Key Takeaway
Context managers replace try/finally for resource cleanup.
Exception chaining with from preserves the root cause traceback.
Use from None only when the original exception is an implementation detail callers shouldn't see.

When NOT to Catch Exceptions — The Pattern That Protects Your Whole System

Knowing when to catch is only half the skill. Knowing when to let exceptions propagate is just as important — and most intermediate developers get this wrong.

The core rule: only catch an exception if you can actually DO something useful with it at that level. If your function can't recover from a database being down, it shouldn't catch that error — let it bubble up to the layer that can (the request handler, which can return a 503 response). Catching and re-raising without adding value is just noise.

The second rule: never use exceptions for flow control in your happy path. Some developers write try/except to check if a key exists in a dictionary instead of using 'in' or .get(). This makes code harder to read and has a performance cost on the failure branch.

The third rule: be very precise about WHAT you catch. A bare 'except:' with no exception type catches absolutely everything — including KeyboardInterrupt, SystemExit, and GeneratorExit. This can make your program impossible to stop with Ctrl+C, mask genuine bugs, and swallow signals your OS sends. It's one of the most dangerous patterns in Python.

api_handler.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import json
from typing import Optional

# --- Simulate a simplified web request/response cycle ---

class HttpResponse:
    def __init__(self, status_code: int, body: dict):
        self.status_code = status_code
        self.body = body

    def __repr__(self):
        return f"HttpResponse({self.status_code}, {self.body})"


# --- Low-level parser — only handles what it knows about ---

def parse_request_body(raw_body: str) -> dict:
    """
    Parses a JSON string into a dict.
    DOES NOT catch exceptions — it has no idea what to do with a parse failure.
    Lets json.JSONDecodeError propagate to whoever called it.
    """
    # No try/except here — this function's job is parsing, not error handling
    return json.loads(raw_body)


def validate_user_payload(payload: dict) -> None:
    """
    Checks that required fields are present.
    Raises ValueError with a descriptive message — doesn't catch anything.
    """
    required_fields = {"username", "email", "password"}
    missing = required_fields - payload.keys()

    if missing:
        # Raise with enough detail for the handler to build a useful error response
        raise ValueError(f"Missing required fields: {sorted(missing)}")


# --- High-level handler — THIS is the right place to catch exceptions ---
# It has enough context to turn failures into proper HTTP responses.

def handle_register_request(raw_request_body: str) -> HttpResponse:
    """
    Handles a user registration API request.
    This is the ONLY layer that should catch exceptions — because it's the only
    layer that knows how to turn them into HTTP responses the client understands.
    """
    try:
        # Step 1: Parse — could raise json.JSONDecodeError
        payload = parse_request_body(raw_request_body)

        # Step 2: Validate — could raise ValueError
        validate_user_payload(payload)

        # Step 3: (In real code: save to DB, hash password, etc.)
        username = payload["username"]

    except json.JSONDecodeError:
        # We CAN handle this here: it means 400 Bad Request
        return HttpResponse(
            status_code=400,
            body={"error": "Request body must be valid JSON"}
        )

    except ValueError as validation_error:
        # We CAN handle this here: it means 422 Unprocessable Entity
        return HttpResponse(
            status_code=422,
            body={"error": str(validation_error)}
        )

    # Note: We do NOT catch Exception here.
    # If something unexpected blows up (DB is down, OOM), let it propagate
    # to the framework's top-level error handler, which will log it properly
    # and return a 500 without leaking stack traces to the client.

    else:
        return HttpResponse(
            status_code=201,
            body={"message": f"User '{username}' registered successfully"}
        )


# --- Run test cases ---

print("=== Valid registration request ===")
valid_body = '{"username": "bob", "email": "bob@example.com", "password": "s3cure!"}'
response = handle_register_request(valid_body)
print(response)

print("\n=== Malformed JSON ===")
response = handle_register_request("{this is not json}")
print(response)

print("\n=== Missing fields ===")
partial_body = '{"username": "carol"}'
response = handle_register_request(partial_body)
print(response)
Output
=== Valid registration request ===
HttpResponse(201, {'message': "User 'bob' registered successfully"})
=== Malformed JSON ===
HttpResponse(400, {'error': 'Request body must be valid JSON'})
=== Missing fields ===
HttpResponse(422, {'error': "Missing required fields: ['email', 'password']"})
Watch Out: The 'except Exception as e: pass' Anti-Pattern
Silently swallowing exceptions with 'pass' is one of the most common causes of 'ghost bugs' — programs that appear to work but produce wrong results because errors are being silently discarded. At minimum, log the exception. In production, use 'logging.exception(e)' which captures the full traceback automatically.
Production Insight
A web API that catches everything in every function will return 200 OK for a database outage.
Your monitoring won't fire because no 5xx is generated, but data goes missing.
Rule: let exceptions propagate to the outermost handler that can return a meaningful HTTP status code and log the stack trace.
Key Takeaway
Only catch exceptions at the level that can meaningfully handle them.
Never use exceptions for flow control — it's slower and less readable.
Bare except: is a production time bomb — always name what you catch.

Exception Handling in Async Code — The Silent Failures Senior Engineers Watch For

Async Python (asyncio) adds a new layer of complexity to exception handling. A coroutine that raises an exception behaves differently depending on how it's run. If you await it inside a try/except, the exception is caught normally. But if a coroutine is scheduled on the event loop but never awaited — a 'fire-and-forget' pattern — the exception is silently swallowed and logged to the event loop's exception handler. You'll never see it unless you configure that handler.

Another common pitfall is exceptions inside TaskGroup or asyncio.gather(). If one task raises, the entire group is cancelled and all other running tasks get CancelledError. You need to handle that cancellation properly or you'll leak tasks and resources.

The rule for async code: always assign the result of an async operation to a variable, even if you don't need it, to ensure exceptions are surfaced. Use TaskGroup (Python 3.11+) for structured concurrency — it forces you to handle exceptions at the point of spawning.

Context managers work in async code too, via __aenter__ and __aexit__. The same finally-like cleanup guarantee applies, but you must use 'async with' and the context manager must implement the async protocol.

io/thecodeforge/async_exceptions.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import asyncio
import logging

# Configure the event loop to log unhandled exceptions
logging.basicConfig(level=logging.ERROR, format='%(asctime)s - %(levelname)s - %(message)s')

async def risky_io() -> str:
    """Simulates an I/O operation that fails randomly."""
    await asyncio.sleep(0.1)
    raise ConnectionError("Database connection refused")

async def safe_worker():
    """Properly awaits and catches exceptions."""
    try:
        result = await risky_io()
        print(f"Result: {result}")
    except ConnectionError as e:
        logging.exception(f"Worker failed with connection error: {e}")
        return None

async def fire_and_forget_worker():
    """Schedules a coroutine but never awaits it — exception is lost."""
    task = asyncio.create_task(risky_io())
    # Not awaited: task executes but its exception disappears into the loop's exception handler
    # This is almost always a bug
    await asyncio.sleep(0.2)  # Wait long enough for the task to fail
    # By now, the exception has been swallowed by the event loop

async def task_group_worker():
    """Uses TaskGroup to ensure all exceptions are surfaced."""
    try:
        async with asyncio.TaskGroup() as tg:
            task1 = tg.create_task(risky_io())
            task2 = tg.create_task(risky_io())  # This will trigger cancellation of task1
    except* ConnectionError as e:
        # Python 3.11+ exception groups allow catching multiple exceptions
        logging.exception(f"TaskGroup failed with {len(e.exceptions)} connection errors")

async def main():
    print("=== Safe worker (catches exception correctly) ===")
    await safe_worker()

    print("\n=== Fire-and-forget worker (exception lost) ===")
    await fire_and_forget_worker()
    print("[WARNING] No exception printed above — it was swallowed by the event loop.")

    print("\n=== TaskGroup worker (surfaces all exceptions) ===")
    await task_group_worker()

if __name__ == "__main__":
    asyncio.run(main())
Output
=== Safe worker (catches exception correctly) ===
ERROR:root:Worker failed with connection error: Database connection refused
=== Fire-and-forget worker (exception lost) ===
[WARNING] No exception printed above — it was swallowed by the event loop.
=== TaskGroup worker (surfaces all exceptions) ===
ERROR:root:TaskGroup failed with 2 connection errors
mental_model: The Event Loop as an Unattended Swallowing Machine
  • Python's event loop has a default exception handler that logs to stderr, but only if you set up logging early.
  • If your fire-and-forget task fails, the exception is caught by the loop, logged only if you've configured loop.set_exception_handler().
  • Most developers don't configure that handler — so exceptions vanish silently.
  • Use TaskGroup (3.11+) to force exception handling at the point of task creation.
  • Always store a reference to the task if you need to check its result later with .exception().
Production Insight
A fire-and-forget coroutine in a webhook handler can fail silently for months.
Data integrity breaks, but no alert fires because the exception is swallowed by the event loop.
Rule: if you must fire-and-forget, assign to a variable and add a callback with .add_done_callback() to log failures.
Key Takeaway
Async exceptions are caught by await, but vanish without trace in fire-and-forget tasks.
Configure the event loop's exception handler on app startup.
Use TaskGroup for structured concurrency — it surfaces every exception.

Why Bare except Clauses Cost You Your Job

A bare except: clause catches every exception, including KeyboardInterrupt, SystemExit, and GeneratorExit. You just silently swallowed the user hitting Ctrl+C or the OS sending a termination signal. Worse, you masked the bug that would have told you your database connection pool is exhausted. Production incidents start here. When you catch everything, you debug nothing. The only acceptable catch-all is except Exception:, and even that needs a solid reason—like logging the full traceback and re-raising. If you're tempted to use bare except: for "safety," you're actually building an opaque box that will fail in mysterious ways at 3 AM. The rule: specify the exception or don't catch it.

payment_processor.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# io.thecodeforge
# Bare except — the production incident starter pack
import sys

try:
    process_payment(order_id=42, amount=-50.00)
except:
    # Swallows KeyboardInterrupt, SystemExit, everything
    print("Payment failed")  # Logs nothing, re-raises nothing

# The correct pattern — catch only what you expect
try:
    process_payment(order_id=42, amount=-50.00)
except ValueError as e:
    logger.critical("Invalid payment amount: %s", e)
    raise  # Re-raise to prevent silent corruption
Output
Payment failed
INFO:root:Invalid payment amount: Negative amount -50.0
Traceback (most recent call last):
File "payment_processor.py", line 9, in <module>
process_payment(order_id=42, amount=-50.00)
ValueError: Negative amount -50.0
Production Trap:
Bare except: also catches MemoryError. If your system is OOM, you just silently failed to allocate memory and kept running in a zombie state. You want the process to die fast so your orchestrator can restart it cleanly.
Key Takeaway
Never use bare except. If you can't name the exception, you shouldn't catch it.

Logging Exceptions Like You Mean It — The Traceback Is Your Evidence

print() in an except block is commit-level negligence. When a payment fails at 2 AM, you need the full traceback, the input state, and the server context. Use logger.exception() — it automatically includes the stack trace and respects your logging levels. Never log the exception manually with str(e); you lose the line number and call stack. Every minute you waste reproducing an error without a traceback is a minute your users are angry. Pattern: log the exception, log the context (user ID, request ID, order amount), then decide: re-raise, return a fallback, or swallow. But know that swallowing without logging is lying to your future self.

api_handler.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# io.thecodeforge
# Bad — loses traceback
import logging

try:
    user = fetch_user(user_id=1001)
except Exception as e:
    print(f"Failed: {e}")  # No stack trace, no context

# Correct — preserves evidence
try:
    user = fetch_user(user_id=1001)
except Exception:
    logger.exception("Failed to fetch user %s", 1001)
    raise  # Let the caller handle retry logic
Output
ERROR:root:Failed to fetch user 1001
Traceback (most recent call last):
File "api_handler.py", line 12, in <module>
user = fetch_user(user_id=1001)
File "/app/db/queries.py", line 44, in fetch_user
return session.query(User).filter_by(id=user_id).one()
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
sqlalchemy.exc.NoResultFound: No row was found for one()
Senior Move:
Add a correlation ID to every request. Log it with every exception. Now you can trace a user's entire failure path across microservices.
Key Takeaway
Log with logger.exception() or log the traceback yourself. Print is for prototyping, not production.
● Production incidentPOST-MORTEMseverity: high

The Silent Retry Storm — Unhandled Exception in Celery Worker

Symptom
Worker processes pegged at 100% CPU, queue length growing exponentially, no errors in application logs.
Assumption
The task handles all exceptions and logs them. The developer assumed that a bare try/except would capture everything and write to the log.
Root cause
The except block used except: (bare) but the code inside it raised a new exception (e.g., logging connection failure) which was silently suppressed by the bare except. Also, there was no logging statement inside the except block — the developer forgot to add log.exception(). The task retried indefinitely because the exception was swallowed and no failure signal sent to the broker.
Fix
Replace bare except with except Exception:. Add log.exception("Task failed due to unhandled error") inside the except block. Set max_retries on the task to prevent infinite retries. Implement a circuit breaker pattern for database-dependent tasks.
Key lesson
  • Never use bare except: in production code — always specify exception type.
  • Always log exceptions at the point of capture using log.exception() which includes the full traceback.
  • Set explicit retry limits on background tasks to avoid retry storms.
  • Test failure scenarios: inject a mock exception to verify logging and retry behaviour.
Production debug guideSymptom → Action grid for common exception handling failures4 entries
Symptom · 01
No errors in logs but task results are missing or wrong
Fix
Check for bare except: that silently swallows exceptions. Run with PYTHONWARNINGS=error to turn all warnings into errors. Add raise to re-raise if unsure.
Symptom · 02
Traceback shows 'During handling of the above exception, another exception occurred'
Fix
Someone raised a new exception inside an except block without chaining. Look for raise NewError(...) without from original. Add from e to preserve the chain.
Symptom · 03
Program hangs on exit or ignores KeyboardInterrupt
Fix
A bare except: in a long-running loop catches KeyboardInterrupt. Replace with except KeyboardInterrupt: raise to allow clean exit, or use except Exception: to avoid catching it.
Symptom · 04
File handle leaks after exception in a with statement
Fix
Check if the context manager is implemented correctly. Ensure __exit__ is called and cleanup happens. Use contextlib.closing() or @contextmanager from contextlib.
★ Quick Debug Cheat Sheet for Python ExceptionsCommon exception symptoms, immediate actions, commands, and fixes.
Script crashes with unhandled exception and full traceback printed to stderr
Immediate action
Grab the traceback. Look at the last exception in the chain — that's the one you need to catch. Identify the line number and exception type.
Commands
python -X dev script.py # Enables developer mode: shows source line references, memory dumps on crash
import traceback; traceback.print_exc() # In a repl or script to print the last traceback manually
Fix now
Wrap the offending line in a try/except block tailored to the exception type. Add logging with logging.exception() and re-raise only if necessary.
Exception caught but no logs written+
Immediate action
Check the except block for any logging call. If there's a bare 'pass', add at least logging.warning() or logging.exception().
Commands
grep -rn 'except:' . --include='*.py' # Find all bare except clauses in the project
python -c "import logging; logging.basicConfig(level=logging.DEBUG); raise ValueError('test')" # Test logging setup
Fix now
Replace bare except with except Exception as e: and add logging.exception('Error description'). Ensure logging is configured to output to stderr or a file.
finally block prevents exception from propagating+
Immediate action
Check if finally contains a return or raises a new exception. Remove any return in finally — it shadows the original exception.
Commands
python -c "def f(): try: raise ValueError('original'); finally: return 'shadow' print(f())" # See shadow
python -W error::RuntimeError # Test for unexpected behavior
Fix now
Remove return statements from finally blocks. If you must execute cleanup that might fail, wrap it in a nested try/except with logging.
ClauseWhen It RunsCan Access Exception?Typical Use Case
tryAlways — it's the guarded blockN/A — exceptions are raised hereWrap any code that might fail
except ExceptionTypeOnly when a matching exception is raisedYes — via 'as error'Handle specific, known failure modes
elseOnly when try completes with NO exceptionNo — no exception occurredSuccess-path logic, kept separate from error handling
finallyAlways — exception or not, even after returnOnly if re-raised manuallyResource cleanup: close files, DB connections, locks

Key takeaways

1
The else clause runs only on success
use it to separate 'risky operation' from 'happy-path logic' and your code's intent becomes immediately readable.
2
Custom exceptions should carry structured data (error codes, context IDs, is_retryable flags)
they're not just messages, they're data packets your callers can act on.
3
Only catch an exception at the layer that has enough context to handle it meaningfully
catching too early hides bugs; catching too late means ugly user-facing crashes.
4
finally is your unconditional cleanup guarantee
it runs even if the except clause raises its own exception, even after a return statement, making it the only safe place for resource teardown.
5
Async exceptions require special attention
fire-and-forget tasks swallow exceptions silently. Use TaskGroup or attach a done callback to surface them.
6
Exception chaining with 'from' preserves the root cause traceback
always chain when wrapping an exception to keep debugging fast.

Common mistakes to avoid

5 patterns
×

Catching bare 'except:' instead of 'except Exception:'

Symptom
Your program becomes impossible to stop with Ctrl+C because KeyboardInterrupt is a subclass of BaseException, not Exception, and bare 'except:' catches everything including it.
Fix
Always name the exception type. Use 'except Exception:' as your widest net, and narrow it down further wherever possible.
×

Putting too much code inside the try block

Symptom
When your try block contains 15 lines, you lose precision about which line actually raised the exception. This makes debugging painful and can cause the wrong except clause to handle an unrelated error.
Fix
Keep try blocks as small as possible — ideally just the one or two lines that can actually fail. Move everything else into the else clause or after the try/except structure.
×

Raising a new exception inside except without 'from', losing the original traceback

Symptom
When you do 'except sqlite3.Error: raise DatabaseError("failed")' without 'from', you get confusing 'During handling of the above exception' messages in tracebacks and you technically lose the __cause__ chain.
Fix
Always write 'raise DatabaseError("failed") from original_error' to make the chain explicit, or 'raise DatabaseError("failed") from None' to explicitly suppress the original when it would confuse callers.
×

Using exceptions for normal flow control

Symptom
Code like 'try: value = dict['key']; except KeyError: value = default' instead of 'dict.get('key', default)'. This is slower (exception overhead ~0.5 µs per raise) and harder to read.
Fix
Use dict.get(), 'if key in dict', or other explicit checks. Exceptions are for exceptional situations, not missing keys.
×

Silent pass in except block

Symptom
Errors are swallowed with no trace — ghost bugs where data silently goes wrong. No logs, no alerts, no way to know something failed.
Fix
At minimum, log with logging.warning(). In critical paths, use logging.exception() to capture the full traceback. If you truly must suppress, add a comment explaining why.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What's the difference between 'except Exception' and a bare 'except:'? W...
Q02SENIOR
Explain exception chaining in Python. What's the difference between 'rai...
Q03SENIOR
A junior dev on your team wrapped an entire 50-line function in a single...
Q01 of 03SENIOR

What's the difference between 'except Exception' and a bare 'except:'? When would you ever use the bare form?

ANSWER
A bare except: catches every subclass of BaseException, including KeyboardInterrupt, SystemExit, and GeneratorExit. except Exception catches only those that inherit from Exception — which includes all application-level errors. Bare except: should almost never be used. One valid case is in a cleanup handler that must run regardless of how the application exits (e.g., closing a hardware device), but even then you likely want to re-raise the exception after cleanup. In production, always use except Exception or more specific types.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between 'raise' and 'raise e' inside an except block in Python?
02
Can a finally block suppress an exception in Python?
03
Should I use LBYL (Look Before You Leap) or EAFP (Easier to Ask Forgiveness than Permission) in Python?
04
What is the performance cost of exception handling in Python?
05
How do I create a custom exception that can be serialized for logging?
N
Naren Founder & Principal Engineer

20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.

Follow
Verified
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
🔥

That's Exception Handling. Mark it forged?

7 min read · try the examples if you haven't

Previous
Property Decorators in Python
1 / 5 · Exception Handling
Next
try-except-finally in Python