Senior 15 min · June 04, 2026

Python Try-Except-Finally — Silent NameError Leaks

A NameError in finally overwrites the original exception, leaking DB connections every 72 hours.

N
Naren Founder & Principal Engineer

20+ years shipping production Python across data and backend systems. Notes here come from systems that actually shipped.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • try/except/finally is Python's structured way to catch and respond to runtime errors
  • try wraps a single risky operation; except catches specific exception types
  • else runs only when try succeeds; finally always runs for cleanup
  • Performance insight: In CPython 3.11+, a try block with no exception raised has near-zero overhead — the real cost is in raising and catching. Exception-heavy hot loops can see measurable slowdowns, so avoid using exceptions as flow control in performance-sensitive paths
  • Production insight: bare except: hides bugs — include it and your monitoring goes blind to entire categories of failure
  • Biggest mistake: returning a value from finally suppresses any in-flight exception silently — Python won't warn you, the exception just vanishes
✦ Definition~90s read
What is try-except-finally in Python?

Python's try-except-finally is the language's structured mechanism for handling runtime errors while guaranteeing cleanup execution. The try block contains code that may raise exceptions; except catches and handles specific exceptions; finally always runs regardless of whether an exception occurred, was caught, or propagated.

Imagine you're baking a cake.

This is critical because finally executes even if the try block contains a return, break, continue, or an unhandled exception — something you cannot replicate with code placed after the entire try-except block. A common pitfall is a silent NameError leak: if you reference a variable inside finally that was only defined inside try and an exception occurs before that assignment, Python raises a NameError inside finally, which silently replaces the original exception.

This behavior stems from Python's execution model — finally runs before the exception propagates, and if finally itself raises, it overwrites the pending exception. Senior devs avoid this by initializing resources before try, using context managers (with statements) for resource management, and structuring try-except to minimize code in finally.

Context managers via __enter__ and __exit__ are the idiomatic replacement for manual try-finally patterns, as seen in file I/O, database connections, and locks — they guarantee cleanup without the variable scope pitfalls.

Plain-English First

Imagine you're baking a cake. You TRY to crack the egg cleanly. If you drop the shell in the batter, you EXCEPT that mistake and fish it out. No matter what happens — success or shell disaster — you FINALLY wash your hands before leaving the kitchen. That's exactly what try-except-finally does in Python: it lets your program attempt something risky, handle any mess that results, and always clean up afterwards — no matter what.

Every program talking to the outside world lives with uncertainty: files vanish, networks go down, users type garbage. Without a plan, your application crashes with a traceback. Python's try-except-finally blocks give you that plan. They let you catch specific failures, respond intelligently, and guarantee cleanup — every time.

try holds the risky operation. except catches what you can handle. else runs only on success. finally always runs — for cleanup. That's it. The subtlety is in how you combine them. A single misplaced line inside try can silently misclassify a bug.

By the end of this article you'll know exactly when to use each block, why else isn't optional, and how to avoid the two patterns that cause the most production outages: suppressing exceptions and leaking resources.

How Python's try-except-finally Actually Handles Errors

The try-except-finally block is Python's structured exception handler: code in the try block runs first; if an exception occurs, execution jumps to the matching except block; the finally block always executes, regardless of whether an exception was raised or caught. This is not optional cleanup — it's guaranteed by the interpreter, even if the except block itself raises a new exception or a return statement is hit.

Key property: the finally block runs before the exception propagates upward. If you return from finally, that return value replaces any exception or return from try/except. This is a common source of silent bugs — a return in finally swallows the original exception entirely.

Use finally for resource release: closing file handles, database connections, or releasing locks. Without it, an exception leaves resources dangling. In production systems, a missing finally on a database cursor can exhaust connection pools within minutes under load.

Return in finally swallows exceptions
If you put a return statement inside finally, it overrides any exception or return from the try or except blocks — the exception is silently discarded.
Production Insight
A payment processing service used a return in finally to log a message, which silently swallowed a ValueError from a malformed transaction payload.
The symptom: successful HTTP 200 responses for failed payments, with no error logs — customers were charged but orders never created.
Rule: never use return, break, or continue inside a finally block; if you need conditional cleanup, use a flag variable.
Key Takeaway
finally always runs, even on exception or return — use it for mandatory cleanup, not logic.
A return in finally discards the current exception — this is almost always a bug.
Prefer context managers (with statement) over try-finally for resource management when possible.
Python Try-Except-Finally Execution Flow THECODEFORGE.IO Python Try-Except-Finally Execution Flow How silent NameError leaks occur in exception handling try Block Code that may raise an exception except Block Catches and handles specific exceptions else Block Runs if no exception occurred finally Block Always executes, even on return/break NameError Leak Variable from except block leaks into scope ⚠ Deleted variable in except block still exists in scope Use del or context managers to avoid leaks THECODEFORGE.IO
thecodeforge.io
Python Try-Except-Finally Execution Flow
Try Except Finally Python

The Anatomy of try-except-finally: What Each Block Actually Does

Python gives you four distinct blocks you can combine around risky code: try, except, else, and finally. Most tutorials explain what they are. This section focuses on why they're separated — because each boundary is a deliberate design decision that prevents a specific category of bug.

try holds only the code that might fail. The discipline here matters more than it looks: the wider your try block, the harder it becomes to know which line actually raised the exception. Keep it to one logical operation.

except catches a specific exception type and lets you respond to a specific failure mode. You can stack multiple except clauses for different exception classes. Catching broad Exception is sometimes the right call, but it should always be a deliberate choice, not a lazy one.

else runs only when try completes without raising any exception. This block is chronically underused and it solves a real problem. If you put post-success processing inside the try block, any exception it raises will be caught by your own except handlers — masking a completely different bug as if it were the original risky operation failing. Moving that logic into else means only the one risky line lives in try, and exceptions from processing surface cleanly as new, unrelated errors. The else block is essentially a contract: 'this code only runs when the thing above succeeded.'

finally runs unconditionally — success, failure, even if you hit a return statement or re-raise an exception inside except. It exists purely for cleanup: closing file handles, releasing locks, disconnecting from external resources. The Python runtime guarantees it runs before anything else — including call stack unwinding for a re-raised exception.

One important nuance that trips people up: if finally itself raises an exception, that new exception becomes the active one and permanently discards whatever was originally in flight. This is why cleanup code in finally must be robust — guard every resource access, and if you log inside finally, make sure the logger can't itself throw.

io/thecodeforge/basics/basic_exception_anatomy.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 os

def read_user_config(filepath: str) -> dict:
    """
    Reads a key=value config file and returns it as a dict.
    Demonstrates the purpose of each exception handling block.
    """
    config_file = None  # Always initialise before try — see finally guard below
    try:
        # Narrow try: only the single risky IO operation lives here
        config_file = open(filepath, 'r', encoding='utf-8')
        raw_contents = config_file.read()
    except FileNotFoundError:
        # We can handle this meaningfully: return a safe default
        print(f"[CONFIG] {filepath} not found. Returning defaults.")
        return {}
    except PermissionError:
        # We can handle this meaningfully too: tell the caller why
        print(f"[CONFIG] Permission denied for {filepath}. Returning defaults.")
        return {}
    else:
        # else only runs when try succeeded — processing lives here, not in try.
        # Why? If this parsing logic raises an exception, it won't be caught by
        # the FileNotFoundError or PermissionError handlers above — it surfaces
        # cleanly as an unhandled error, which is exactly what we want.
        parsed = {}
        for line in raw_contents.splitlines():
            if '=' in line and not line.startswith('#'):
                key, value = line.split('=', 1)
                parsed[key.strip()] = value.strip()
        return parsed
    finally:
        # finally always runs — success, exception, or early return.
        # The 'is not None' guard prevents NameError if open() itself failed.
        if config_file is not None:
            config_file.close()
            print("[CONFIG] Cleanup: file handle closed.")


if __name__ == '__main__':
    # Scenario 1: valid config file exists
    # Expected: file is read, parsed, handle closed
    print("=== Scenario: Valid file ===")
    result = read_user_config('sample.cfg')
    print(f"Result: {result}")

    # Scenario 2: file does not exist
    # Expected: FileNotFoundError caught, defaults returned, no handle to close
    print("\n=== Scenario: Missing file ===")
    result = read_user_config('missing.cfg')
    print(f"Result: {result}")
Output
=== Scenario: Valid file ===
[CONFIG] Cleanup: file handle closed.
Result: {'theme': 'dark'
Production Insight
The most common production bug from block misuse is a too-wide try block that causes except handlers to catch unrelated exceptions. This manifests as misleading error messages: a data bug looks like an IO error, wasting hours during incidents.
Key Takeaway
Keep try blocks narrow. Use else for post-success logic. Never let finally raise its own exception.
Pro Tip: else Is a Contract, Not a Convenience
The else block communicates intent to the next developer reading your code: 'everything in here only runs when the risky operation above succeeded.' That's worth more than the few extra lines it saves. If you put post-success logic inside try, a bug in your parsing code looks identical to a missing file — same except handler catches both. The else block makes those two failure modes structurally impossible to confuse.

Why finally Exists — And Why You Cannot Fake It With Code After the Block

A common instinct when first learning exception handling is to put cleanup code after the try-except block. It looks equivalent. It isn't — and the gap between the two is exactly where production incidents happen.

Picture a database connection. You open it in try, run a query, and an exception fires. Your except block re-raises. Execution never reaches the line below the try-except — the connection leaks. Over time the pool exhausts itself, and at 3am a pager goes off.

finally solves this because Python guarantees it runs before the interpreter does anything else — including unwinding the call stack for a re-raised exception, executing a return statement, or responding to a break or continue inside a loop. 'After the block' gives you none of those guarantees.

There is one important caveat that trips even experienced engineers: if the finally block itself raises an exception, that new exception becomes the active one and the original exception is gone. A NameError on a variable you forgot to initialise, a failed logging call, a second network error during cleanup — any of these inside finally will silently discard the exception you actually cared about. This is not theoretical; it's the root cause of a class of production bugs where error logs go mysteriously silent.

The defensive pattern is always: initialise your resource variable to None before the try block, then guard every usage in finally with an explicit 'if resource is not None' check. Better still, use a context manager — the with statement automates exactly this pattern without the risk.

finally is the right place for exactly three categories of logic: closing file handles and sockets, releasing locks, and resetting shared state. It is never the right place to return a value or to make a decision about the application's control flow.

io/thecodeforge/db/database_connection_cleanup.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
import sqlite3

def fetch_active_users(db_path: str) -> list:
    """
    Fetches usernames from the database.
    Demonstrates safe resource cleanup with finally and the None-guard pattern.
    """
    connection = None  # Guard: if connect() raises, 'connection' is still defined
    try:
        print("[DB] Opening connection...")
        connection = sqlite3.connect(db_path)
        cursor = connection.cursor()
        cursor.execute("SELECT username FROM users")
        return cursor.fetchall()
    except sqlite3.OperationalError as e:
        # We can add context and re-raise — caller decides what to do
        print(f"[DB] Query failed: {e}")
        raise
    finally:
        # Runs whether the query succeeded, failed, or we hit the return above.
        # The 'is not None' check prevents AttributeError if connect() itself raised.
        if connection is not None:
            connection.close()
            print("[DB] Connection closed.")


# The context manager version — preferred in real codebases
# sqlite3.connect() returns a context manager that auto-commits or rolls back
# and closes the connection, with no manual finally required.
def fetch_active_users_clean(db_path: str) -> list:
    """
    Same logic using a context manager.
    The 'with' statement is try-finally under the hood — __exit__ is always called.
    """
    with sqlite3.connect(db_path) as connection:
        cursor = connection.cursor()
        cursor.execute("SELECT username FROM users")
        return cursor.fetchall()


if __name__ == '__main__':
    print("=== Manual try-finally ===")
    try:
        users = fetch_active_users('app.db')
        print(f"Users: {users}")
    except sqlite3.OperationalError:
        print("Could not retrieve users.")

    print("\n=== Context manager version ===")
    with sqlite3.connect('app.db') as conn:
        rows = conn.execute("SELECT username FROM users").fetchall()
        print(f"Users: {rows}")
Output
=== Manual try-finally ===
[DB] Opening connection...
[DB] Connection closed.
Users: [('alice',), ('bob',)]
=== Context manager version ===
Users: [('alice',), ('bob',)]
Watch Out: Never Return a Value From finally
If you write 'return result' inside a finally block, it silently suppresses any exception that was in flight and overrides any return value from try or except. Python will not warn you. The exception simply vanishes. Reserve finally for side effects only — closing, logging, resetting state. The moment you put a return inside finally, you have created a silent exception suppressor that will eventually cost someone hours of debugging.
Production Insight
A return inside finally caused a critical data loss bug: the function suppressed the exception but also returned a stale cached value. The system continued as if nothing happened, but the underlying data was never updated. The fix was a linter rule that rejects any return statement inside finally.
Key Takeaway
finally is for side effects only — never return a value from it. A return in finally silently suppresses any in-flight exception and overrides the try/except return.

Context Managers: The Right Way to Replace Manual try-finally

Once you understand why finally exists, you'll immediately see what context managers are: a protocol that automates the try-finally pattern and makes it impossible to forget. Every time you write 'with open(path) as f:', Python is calling __enter__ at the start and guaranteeing __exit__ is called at the end — regardless of exceptions. That's literally the with statement's entire job.

Why does this matter beyond convenience? Because manual try-finally has two failure modes that context managers eliminate by design. First, you might forget to write the finally block at all. Second, even if you write it, a NameError on an uninitialised variable inside finally can discard the original exception — as shown in the production incident above. A context manager's __exit__ receives the exception as an argument, so it always has something to work with.

Building your own context manager is straightforward. You either implement __enter__ and __exit__ on a class, or use the contextlib.contextmanager decorator on a generator function. The generator approach is usually cleaner for simple cases.

The rule of thumb used in most production codebases: if you find yourself writing try...finally for resource management, ask whether a context manager already exists for that resource. For files, sockets, locks, database connections, and HTTP sessions, they almost always do. Write manual try-finally only when a context manager genuinely doesn't exist or when the cleanup logic is too complex for the protocol.

io/thecodeforge/context/context_manager_patterns.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
import contextlib
import sqlite3
import time

# Pattern 1: Class-based context manager
# Use this when you need fine-grained control or stateful cleanup logic.
class TimedOperation:
    """
    Context manager that measures and logs the duration of a block.
    __enter__ runs at the start; __exit__ is guaranteed to run at the end.
    """
    def __init__(self
Output
[TIMER] Starting: config load
Working inside: config load
[TIMER] config load — OK in 0.0501s
[DB] Connection opened: :memory:
[DB] Query result: (42,)
[DB] Connection closed: :memory:
=== Exception path ===
[TIMER] Starting: failing operation
[TIMER] failing operation — FAILED in 0.0001s
Exception caught outside the with block — cleanup still ran.
Pro Tip: __exit__ Returning True Is a Silent Exception Suppressor
If __exit__ returns True, Python treats the exception as handled and does not propagate it. This is occasionally the right call — suppressing a specific expected exception type — but it should always be intentional. Returning False or None (the default) lets the exception propagate normally. Most context managers should return False. If yours returns True unconditionally, you've replicated the exact problem of bare except: pass, just with more ceremony.
Production Insight
In a release that replaced manual try-finally with context managers, the connection leak rate dropped from 3% of requests to zero. The change caught the NameError-in-finally bug class completely because __exit__ receives the exception as a parameter — no unbound variables possible.
Key Takeaway
Prefer context managers over manual try-finally for every resource you acquire. They eliminate the two most common failure modes: forgetting cleanup and NameError inside finally.

Real-World Patterns: How Senior Devs Actually Structure Exception Handling

There's a meaningful gap between exception handling that passes code review and exception handling that holds up at 2am under production load. Senior engineers follow a small set of consistent patterns that beginners skip because the reasoning isn't obvious until you've been burned.

Pattern 1 — Catch specific, re-raise general. Catch the exceptions you can actually handle meaningfully at the current layer. If you can't retry it, log extra context, or provide a safe fallback, let it bubble up to someone who can.

Pattern 2 — Log at the boundary. Log an exception exactly once: at the layer where you decide not to re-raise it. Logging at every layer produces duplicate log lines and makes it harder to find the actual decision point during an incident.

Pattern 3 — Custom exceptions carry context. Define your own exception classes for domain-level errors. A raised ApiRequestError(status=429, retry_after=30) gives the caller concrete information to act on. A generic RuntimeError('API failed') gives them nothing.

Pattern 4 — Chain exceptions with raise X from Y. When you catch a low-level exception and raise a higher-level one, always chain them. This preserves the original traceback as __cause__ on the new exception. Without it, the root cause is gone and the next engineer will spend an hour reconstructing it.

Pattern 5 — Exception groups for concurrent work (Python 3.11+). When you run multiple tasks concurrently — via asyncio.gather or a ThreadPoolExecutor — multiple exceptions can fire simultaneously. Python 3.11 introduced ExceptionGroup and the except* syntax specifically for this case. It lets you handle different exception types from a group independently rather than forcing you to pick one.

io/thecodeforge/api/api_client_with_retry.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import urllib.request
import urllib.error
import time
import logging

logger = logging.getLogger("io.thecodeforge.api")


# Pattern 3: Custom exception with domain context
class ApiRequestError(Exception):
    """
    Raised when an API call fails in a way the caller can respond to.
    Carries structured data — not just a message string.
    """
    def __init__(self
Output
WARNING: Attempt 1/2 failed with HTTP 503 — retrying in 1s
WARNING: Attempt 2/2 failed with HTTP 503 — retrying in 1s
Caught: API Error 503: All retries exhausted
Original cause: HTTP Error 503: SERVICE UNAVAILABLE
Handled 2 validation errors:
- Task 1: invalid input
- Task 3: invalid input
Handled 1 timeout(s) — will retry
Interview Gold: Exception Chaining With 'raise X from Y'
When you catch a low-level exception and raise a higher-level custom one, always use 'raise MyError(...) from original_error'. This sets __cause__ on the new exception and Python prints both tracebacks with a clear chain. Without it, the original traceback vanishes and the next engineer debugging the issue will only see the new exception — with no indication of what actually caused it. Interviewers ask about this specifically because it separates engineers who write exception handling from engineers who have debugged it in production.
Production Insight
A team that never chained exceptions spent 3 hours tracing a payment failure because the original database error was hidden. After enforcing 'raise X from Y' in code review, the same failure was resolved in 15 minutes — the traceback showed exactly which query failed and why.
Key Takeaway
Always chain exceptions with 'raise X from Y'. Without it, the original traceback is lost and debugging becomes guesswork. Use 'from None' only when you intentionally hide the internal error.

Custom Exception Classes: How to Extend the Built-in Hierarchy

Python's built-in exceptions (ValueError, TypeError, RuntimeError) cover a wide range, but in a real application they lack domain context. A generic RuntimeError('authentication failed') doesn't tell the caller whether they should retry, re-authenticate, or stop trying. A custom exception that carries structured data — like status codes, retry delays, and error identifiers — gives the calling code something to work with beyond a string message.

Defining a custom exception is as simple as subclassing Exception or one of its subclasses. By convention, custom exceptions end in 'Error' and inherit from Exception rather than BaseException. The standard library follows this pattern: LookupError subclasses KeyError, IndexError; OSError wraps system-level errors. Your domain classes should follow the same principle.

The real power comes from adding constructor arguments beyond the message. When you catch a custom exception, you can inspect those attributes to decide how to respond. For example, a RateLimitedError with a retry_after attribute allows the caller to sleep that many seconds before retrying — something a plain string message cannot express programmatically.

One common pattern is a base class for your project's exceptions (e.g., MyProjectError) that inherits from Exception, then specific subclasses for each domain failure. This lets callers catch the base class if they want a general safety net, or catch specific subclasses for targeted handling. Avoid inheriting from multiple exception classes — it creates confusion about which catch clause will match.

Remember that custom exceptions are also classes, so you can add methods to them. A RetryableError might have a method to compute an exponential backoff. This is overkill for most cases but can be elegant in complex retry logic. In practice, storing attributes is enough.

io/thecodeforge/exceptions/custom_exceptions.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
import time
import random

# Step 1: Define a base exception for your library or application
class PaymentError(Exception):
    """Base exception for all payment-related errors."""
    pass

# Step 2: Subclass for specific failure modes
class InsufficientFundsError(PaymentError):
    def __init__(self
Output
# Multiple runs will show different outputs depending on random
# Example output:
[WARN] gateway.example.com timed out, attempt 1
[WARN] gateway.example.com timed out, attempt 2
[WARN] gateway.example.com timed out, attempt 3
# After retries exhausted, NetworkTimeoutError propagates
# Or:
[ERROR] ACC-123: balance 50.0 < required 100.0
Could not process — insufficient funds.
Design Guideline: One Base Exception Per Project
Define a single abstract base exception for your project (e.g., MyProjectError) that inherits from Exception. All domain exceptions inherit from that. This gives callers a single import for 'catch anything our code might raise' and prevents accidental catching of foreign exceptions. The built-in exception hierarchy uses this pattern — Exception is the root for all user-defined exceptions.
Production Insight
In production, custom exceptions transformed debugging from log scraping to structured alerting. When a payment failed, the monitoring system could extract account_id and balance from the InsufficientFundsError attributes and trigger a targeted notification. Before custom exceptions, every failure was a generic RuntimeError with a message that required human parsing. The change cut mean-time-to-resolution for payment errors by 40%.
Key Takeaway
Custom exceptions let you attach structured data to failure events, turning error handling from string matching into programmatically actionable code. Always include at least one attribute beyond the message that the caller can branch on.

Exception Chaining with 'raise X from Y': Preserving the Full Traceback

When you catch a low-level exception and raise a higher-level one — for example, catching a database connection error and raising a ServiceUnavailableError — you have a choice. You can raise the new exception directly: raise ServiceUnavailableError(). Or you can chain it: raise ServiceUnavailableError() from original_exception. The difference is whether the original traceback survives.

Without 'from', the new exception's __cause__ is left as None. When Python prints the traceback, you only see the new exception. The original error — which query failed, what the database error code was — is gone. During an incident, this forces engineers to reconstruct the root cause from context: which request was in flight, which query was running, what the log line right before the exception says. That reconstruction can take hours.

With 'raise X from Y', Python sets __cause__ on the new exception to the original. The traceback shows both, with the message 'The above exception was the direct cause of the following exception:' between them. The original line number, stack frames, and error message are all preserved.

You can also use implicit chaining without 'from'. If an exception is raised while another exception is being handled and you don't explicitly catch the original, Python sets __context__ instead of __cause__. The traceback then shows 'During handling of the above exception, another exception occurred:' and both are printed. This is automatic, but it's less clear than explicit chaining because the new exception isn't necessarily a direct transformation of the original. Explicit 'from' is preferred whenever you intentionally translate an exception.

The one case where you should use 'from None' is when you want to suppress the original exception entirely — for example, when a socket error should be presented as a simple ConnectionFailedError without exposing internal network details. 'raise ConnectionFailedError('timeout') from None' clears the chain and prints only the new exception. Use this sparingly, because it hides information that may be critical for debugging.

io/thecodeforge/exceptions/chaining_example.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
import sqlite3

class DatabaseError(Exception):
    """Domain-level error for database operations."""
    pass

def get_user_email(user_id: int) -> str:
    """
    Fetches user email from the database.
    Example of bad, good, and suppress chaining.
    """
    try:
        conn = sqlite3.connect('users.db')
        cursor = conn.cursor()
        cursor.execute("SELECT email FROM users WHERE id = ?", (user_id,))
        row = cursor.fetchone()
        if row is None:
            raise ValueError(f"User {user_id} not found")
        return row[0]
    except sqlite3.Error as e:
        # BAD: no chaining — original traceback lost
        raise DatabaseError("Database operation failed")

def get_user_email_chained(user_id: int) -> str:
    """
    Same logic but preserves the original exception via 'from'.
    """
    try:
        conn = sqlite3.connect('users.db')
        cursor = conn.cursor()
        cursor.execute("SELECT email FROM users WHERE id = ?", (user_id,))
        row = cursor.fetchone()
        if row is None:
            raise ValueError(f"User {user_id} not found")
        return row[0]
    except sqlite3.Error as e:
        # GOOD: chain the low-level error into the domain exception
        raise DatabaseError("Database operation failed") from e

def get_user_email_suppressed(user_id: int) -> str:
    """
    Intentional suppression — hide internal details.
    """
    try:
        conn = sqlite3.connect('users.db')
        cursor = conn.cursor()
        cursor.execute("SELECT email FROM users WHERE id = ?", (user_id,))
        row = cursor.fetchone()
        if row is None:
            raise ValueError(f"User {user_id} not found")
        return row[0]
    except sqlite3.Error:
        # Use 'from None' to suppress the original cause
        raise DatabaseError("Database operation failed") from None


if __name__ == '__main__':
    # Example 1: no chaining
    print("=== Without chaining ===")
    try:
        get_user_email(1)
    except DatabaseError as e:
        import traceback
        traceback.print_exc()
        # Output: only DatabaseError: Database operation failed
        # Original sqlite3.Error is lost.

    print("\n=== With chaining (from e) ===")
    try:
        get_user_email_chained(1)
    except DatabaseError as e:
        traceback.print_exc()
        # Output: both DatabaseError and its cause (sqlite3.Error)
        # The line 'The above exception was the direct cause...' appears.

    print("\n=== Suppressed chaining (from None) ===")
    try:
        get_user_email_suppressed(1)
    except DatabaseError as e:
        traceback.print_exc()
        # Output: only DatabaseError — original cause hidden
Output
=== Without chaining ===
Traceback (most recent call last):
File "...", line 22, in get_user_email
raise DatabaseError("Database operation failed")
DatabaseError: Database operation failed
=== With chaining (from e) ===
Traceback (most recent call last):
File "...", line 58, in get_user_email_chained
cursor.execute("SELECT email FROM users WHERE id = ?", (user_id,))
sqlite3.OperationalError: no such table: users
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "...", line 75, in <module>
get_user_email_chained(1)
File "...", line 67, in get_user_email_chained
raise DatabaseError("Database operation failed") from e
DatabaseError: Database operation failed
=== Suppressed chaining (from None) ===
Traceback (most recent call last):
File "...", line 92, in get_user_email_suppressed
raise DatabaseError("Database operation failed") from None
DatabaseError: Database operation failed
Interview Depth: Why 'raise X from Y' Matters
Interviewers ask about exception chaining because it separates engineers who have debugged production incidents from those who haven't. Without chaining, a crash in a complex system often leaves the team guessing which component actually failed. With chaining, the full causal chain is preserved. Mention that you always use 'raise ... from ...' when translating exceptions, and that 'from None' is a deliberate design choice for hiding implementation details.
Production Insight
In a production incident where a payment gateway returned a 503, the team's code was using 'raise ServiceUnavailableError()' without 'from'. The original HTTPError with status code and response body was lost. It took three engineers two hours to correlate the alert with the gateway logs. After adding chaining, the same failure produced a traceback showing the exact HTTP response. The fix cut debugging time from hours to minutes.
Key Takeaway
Always chain exceptions with 'raise X from Y' when you catch a low-level error and raise a domain-level one. Without it, the original traceback vanishes and debugging becomes guesswork. Use 'from None' only when you intentionally want to hide the internal error.

The else Block in Depth: Why It Exists and the Bug It Prevents

The else block is the most misunderstood part of Python's exception handling syntax. Most tutorials mention it briefly and move on. That's a mistake, because the problem it solves is subtle and the consequences of ignoring it are real.

Here's the core issue. When you put post-success logic inside the try block, you're expanding the blast radius of your except handlers. Any exception that code raises — a TypeError in your data processing, a KeyError in a dictionary lookup, a logic bug you haven't discovered yet — will be caught by your except handlers, which were written to handle a completely different failure mode. The error is silently misclassified.

The else block prevents this by drawing a hard structural boundary: only the one risky operation lives in try, and your except handlers apply only to that operation. Everything that should run after success lives in else, where exceptions surface normally without being caught by the wrong handler.

The before/after pattern below makes this concrete. It's a small change that has prevented several production incidents where a data processing bug was being swallowed by a database exception handler and the symptom looked like a connection issue.

io/thecodeforge/basics/else_block_before_after.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
import json
import logging

logger = logging.getLogger("io.thecodeforge")


# ----------------------------------------------------------------
# BEFORE: post-success logic inside try — dangerous
# ----------------------------------------------------------------
def parse_config_dangerous(raw: str) -> dict:
    """
    Bug: if json.loads() succeeds but the subsequent processing raises
    a KeyError or TypeError, that exception is caught by the
    json.JSONDecodeError handler — wrong handler, wrong response.
    """
    try:
        data = json.loads(raw)
        # This processing logic should NOT be inside try.
        # A bug here looks like a JSON parse error to the except below.
        result = {
            "host": data["database"]["host"],   # KeyError if structure wrong
            "port": int(data["database"]["port"]) # ValueError if port is a string
        }
        return result
    except json.JSONDecodeError as e:
        # This handler was written for JSON parse failures.
        # It will now also silently catch KeyError and ValueError from above.
        logger.error("Invalid JSON — returning defaults: %s", e)
        return {\"host\": \"localhost\", \"port\": 5432}\n\n\n# ----------------------------------------------------------------\n# AFTER: post-success logic in else — correct\n# ----------------------------------------------------------------\ndef parse_config_safe(raw: str) -> dict:\n    \"\"\"\n    Safe version: try holds only the risky parse operation.\n    The except handler applies only to JSONDecodeError.\n    Post-success processing lives in else — a KeyError here\n    surfaces as an unhandled exception, not a misclassified parse error.\n    \"\"\"\n    try:\n        data = json.loads(raw)\n    except json.JSONDecodeError as e:\n        # This handler now applies exclusively to JSON parsing failures.\n        logger.error(\"Invalid JSON — returning defaults: %s\", e)\n        return {\"host\": \"localhost\", \"port\": 5432}\n    else:\n        # This code only runs when json.loads() succeeded.\n        # Exceptions here are NOT caught by the JSONDecodeError handler above —\n        # they propagate normally, which is exactly the right behaviour.\n        return {\n            \"host\": data[\"database\"][\"host\"],\n            \"port\": int(data[\"database\"][\"port\"])\n        }\n\n\nif __name__ == '__main__':\n    valid_json = '{\"database\": {\"host\": \"db.prod\", \"port\": \"5432\"}}'\n    broken_structure = '{\"database\": {}}' # missing host and port keys\n    invalid_json = 'not json at all'\n\n    print(\"=== Valid JSON, valid structure ===\")\n    print(parse_config_safe(valid_json))\n\n    print(\"\\n=== Invalid JSON — both versions return defaults ===\")\n    print(parse_config_safe(invalid_json))\n\n    print(\"\\n=== Valid JSON, broken structure (dangerous version) ===\")\n    # Bug: KeyError is swallowed by JSONDecodeError handler — returns default silently\n    print(parse_config_dangerous(broken_structure))\n\n    print(\"\\n=== Valid JSON, broken structure (safe version) ===\")\n    # Correct: KeyError propagates — the caller sees the real bug\n    try:\n        print(parse_config_safe(broken_structure))\n    except KeyError as e:\n        print(f\"Caught real error: KeyError {e} — structure was wrong, not JSON\")",
        "output": "=== Valid JSON, valid structure ===\n{'host': 'db.prod', 'port': 5432}\n\n=== Invalid JSON — both versions return defaults ===\nInvalid JSON — returning defaults: ...\n{'host': 'localhost', 'port': 5432}\n\n=== Valid JSON, broken structure (dangerous version) ===\nInvalid JSON — returning defaults: 'host'\n{'host': 'localhost', 'port': 5432}\n\n=== Valid JSON, broken structure (safe version) ===\nCaught real error: KeyError 'host' — structure was wrong, not JSON"
      }

else Block Use-Case Table: When to Use else vs. Putting Logic in try

Deciding whether to put code in the else block or inside try depends on whether that code can raise an exception that should be handled differently from the risky operation itself. The table below summarises common scenarios.

NoneTEXT
1
2
3
4
5
6
7
8
9
10
11
12
| Scenario                                      | Put in try?   | Put in else?   | Why?                                                                 |
|-----------------------------------------------|---------------|----------------|----------------------------------------------------------------------|
| Risky operation: opening a file               | Yes           | —              | The only line that can raise FileNotFoundError etc.                   |
| Processing: parsing file contents             | No            | Yes            | A parsing KeyError is not an IO error; should propagate separately.  |
| Validation: checking data integrity           | No            | Yes            | A validation error is a logic bug, not an environment failure.       |
| Updating cache after a successful DB write    | No            | Yes            | A cache connection failure is a different concern from DB failure.   |
| Renaming a temporary file after success       | No            | Yes            | If rename fails, that's a different error than the original write.   |
| Logging success (e.g., statsd increment)      | No            | Yes            | Logging failure should be silent or handled elsewhere, not catch the original error. |
| Cleanup that must run regardless (close fd)   | No            | No (finally)   | finally is for unconditional cleanup.                                |
| Code that cannot raise any exception          | No            | No (after)     | If no risk, put it after the entire try-except-else-finally block.   |
| Calling an API that has its own retry logic   | Yes           | —              | The API call is the risky operation itself.                          |
| Sequential risky operations (file then parse) | Separate try   | —              | Use separate try blocks per operation or extract into a function.    |
Quick Rule of Thumb
If the code after the risky operation raises an exception, would you want that exception to be caught by the same except handlers? If no, put it in else. If yes, it belongs inside try but consider narrowing the try to only that operation.
Production Insight
In a codebase where a database fetch and data transformation were both inside try, a transformation bug (KeyError) was consistently reported as 'database failure' in alerts. The on-call engineer would investigate database health, find nothing wrong, and escalate. After moving transformation to else, the true error surfaced immediately, reducing alert triage time from 30 minutes to under 2 minutes.
Key Takeaway
The else block exists to prevent exception misclassification. Use it whenever post-success processing could raise its own exceptions that are logically different from the failure modes of the risky operation.

Common Mistake: Swallowing Exceptions with Bare except

The single most dangerous pattern in exception handling is a bare except: that does nothing. It can turn a manageable bug into silent data corruption.

There are two variants and it's worth being precise about what each one actually catches.

A bare 'except:' (no class specified) catches absolutely everything — including BaseException subclasses like KeyboardInterrupt and SystemExit. This means Ctrl+C during a long operation won't interrupt it, and sys.exit() calls won't work as expected. You are catching things the language explicitly designed to always propagate.

'except Exception:' is slightly more disciplined — it catches all subclasses of Exception but correctly leaves KeyboardInterrupt and SystemExit alone, since those inherit from BaseException directly, not Exception. However, it still catches far too much: ValueError, TypeError, AttributeError, and every other general exception that almost certainly indicates a bug in your own code rather than something you should handle silently.

Imagine a payment processing pipeline where a network timeout occurs. A bare except: pass hides the timeout and carries on as if the charge succeeded, leading to a duplicate charge on the next retry. There is no log, no metric, no alert. The bug is invisible until a customer calls.

The fix is always to name the exception you expect. If you genuinely must catch a broad range, log it at critical level and re-raise immediately. The monitoring system cannot fix what it cannot see.

io/thecodeforge/basics/swallowing_example.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
import logging
logger = logging.getLogger("io.thecodeforge.payment")


# ----------------------------------------------------------------
# WRONG — never do this
# ----------------------------------------------------------------
def process_payment_dangerous(amount: float):
    try:
        charge_api(amount)
    except:  # Catches KeyboardInterrupt, SystemExit, and every bug in charge_api
        pass  # Silent. No log. No metric. No alert. No retry. Just nothing.


# ----------------------------------------------------------------
# ALSO WRONG — slightly less bad, still a trap
# ----------------------------------------------------------------
def process_payment_still_wrong(amount: float):
    try:
        charge_api(amount)
    except Exception:
        pass  # At least KeyboardInterrupt works now. Still logs nothing.


# ----------------------------------------------------------------
# CORRECT — production-grade pattern
# ----------------------------------------------------------------
def process_payment(amount: float):
    """
    Catches only the exceptions we can meaningfully respond to.
    Everything else is logged and re-raised so it surfaces properly.
    """
    try:
        charge_api(amount)
    except TimeoutError:
        # Specific: we know what this is, we know how to respond
        logger.warning("Payment API timeout for amount=%.2f — will retry", amount)
        return None
    except Exception as e:
        # Unexpected: we don't know what this is, so we cannot handle it safely.
        # Log at critical so it pages, then re-raise so execution stops here.
        logger.critical(
            "Unexpected error in payment processing for amount=%.2f",
            amount,
            exc_info=True  # Includes full traceback in the log entry
        )
        raise  # Re-raise: let it surface rather than continue in a broken state
    else:
        logger.info("Payment of %.2f processed successfully", amount)
        return True


def charge_api(amount: float):
    """Placeholder — simulates the external API call."""
    if amount > 10000:
        raise RuntimeError("Amount exceeds single-transaction limit")
    print(f"Charged: {amount}")
Output
# process_payment(50.00):
Charged: 50.00
# INFO: Payment of 50.00 processed successfully
# process_payment(99999.00):
# CRITICAL: Unexpected error in payment processing for amount=99999.00
# Traceback (most recent call last): ...
# RuntimeError: Amount exceeds single-transaction limit
# RuntimeError: Amount exceeds single-transaction limit <- re-raised
Production Insight
A bare except: pass in a payment pipeline caused a production incident where silent failures led to duplicate charges. The monitoring system saw nothing unusual because no exception was ever logged. The first indication of a problem was a customer complaint. The fix replaced every broad except with specific handlers, added logger.exception() before any re-raise, and added a metrics counter increment so the frequency was visible in dashboards even before logs were reviewed.
The diagnostic pattern to search your codebase for: any except block that contains neither a log statement, a counter increment, nor a re-raise is a monitoring blind spot.
Key Takeaway
Never write except: or except Exception: without both logging and re-raising.
If you cannot handle the exception meaningfully, do not catch it — let it propagate to a layer that can.
The minimum safe pattern is: except SpecificError as e: logger.exception('context'); raise

Nesting try Blocks: When and Why It Makes Sense

Sometimes a single try-except isn't enough. You may need to handle different failure modes at different granularities — the availability of a resource is a different kind of failure from the validity of its contents, and conflating the two makes both harder to handle correctly.

Nesting try blocks is a legitimate tool, but it comes with a discipline requirement: the outer try handles resource acquisition and availability errors, the inner try handles data processing and validation errors. Never go deeper than two levels. If you find yourself at a third level, the function is doing too much — extract the inner logic into a separate function that has its own try-except.

A real example: reading a configuration file. The outer try handles whether the file exists and whether we can open it. The inner try handles whether its contents are valid JSON. A corrupt file is not the same as a missing file — different error messages, potentially different recovery strategies — and the two levels make that separation structurally obvious.

io/thecodeforge/config/nested_try_config.pyPYTHON
1
2
3
4
5
6
7
8
import json
from pathlib import Path
import logging

logger = logging.getLogger("io.thecodeforge.config")


def load_config(path: str
Output
=== Missing file ===
[CONFIG] missing.json not found. Using defaults.
Result: {'host': 'localhost'

Catch Multiple Exceptions: Don't Be That Dev Who Uses Bare Except

Production systems don't just crash on ZeroDivisionError. They crash on KeyError, ConnectionTimeout, ValueError, and that one weird UnicodeDecodeError that only happens on Tuesdays. You need to catch them all — but not with a bare except.

Bare except catches KeyboardInterrupt and SystemExit too. That means your deploy script can't kill the process. Your users can't Ctrl+C out of a hang. You've just created an unkillable zombie process. Congratulations.

Tuple your exceptions. Group logical families together. If you're catching ConnectionError and TimeoutError in the same handler, tuple them. Don't cascade five except clauses that all do the same thing — that's how you get copy-paste bugs.

The real pro move? Catch the specific exceptions you can actually recover from. Let everything else bubble up to the caller. They might have context you don't.

MultiCatchPayment.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// io.thecodeforge — python tutorial

def process_payment(payload: dict) -> dict:
    try:
        user_id = payload['user_id']
        amount = float(payload['amount'])
        rate_limit_check(user_id)
        return charge_gateway(user_id, amount)
    except (KeyError, ValueError) as validation_err:
        logger.warning(f"Validation failed: {validation_err}")
        return {'status': 'rejected', 'reason': 'invalid_payload'}
    except RateLimitError:
        return {'status': 'rejected', 'reason': 'rate_limited'}
    except (ConnectionError, TimeoutError) as network_err:
        logger.error(f"Gateway unreachable: {network_err}")
        # Retry logic lives here, not in caller
        return retry_with_backoff(3, process_payment, payload)
Output
>>> process_payment({'user_id': 'abc', 'amount': '49.99'})
{'status': 'rejected', 'reason': 'invalid_payload'}
Production Trap:
Bare except catches BaseException, which includes KeyboardInterrupt and SystemExit. You just made your app uncrashable. Don't.
Key Takeaway
Tuple related exceptions together. Catch only what you can handle. Let the rest propagate.

Built-in vs Custom Exceptions: Don't Reinvent the Wheel. Unless the Wheel Is Square.

I've seen codebases with 47 custom exception classes. 46 of them were pointless wrappers around ValueError or RuntimeError. Don't be that dev.

Python's built-in exceptions cover 95% of real-world cases. KeyError? Use it. ValueError? Perfect. RuntimeError? That's literally what it's for. Inventing a new exception class just so you can have a fancier message in your logs is cargo-cult programming.

You should write a custom exception when you need to distinguish your error from every other error of the same type. A PaymentGatewayTimeout is different from a DNS timeout. They both inherit from TimeoutError, but your retry logic treats them differently. That's when you subclass.

Custom exceptions only earn their keep when callers actually catch them differently. If every custom exception gets caught by a blanket except, you wasted your time. And probably your colleague's time too.

CustomGatewayError.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// io.thecodeforge — python tutorial

class PaymentGatewayError(Exception):
    """Gateway returned a non-recoverable error."""
    def __init__(self, gateway: str, status_code: int, message: str):
        self.gateway = gateway
        self.status_code = status_code
        super().__init__(f"{gateway} returned {status_code}: {message}")

class RateLimitError(PaymentGatewayError):
    """Caller exceeded the gateway's rate limit."""
    pass

def charge_gateway(user_id: str, amount: float) -> dict:
    response = gateway_api.charge(user_id, amount)
    if response.status == 429:
        raise RateLimitError('stripe', 429, 'Retry after 30s')
    if response.status >= 500:
        raise PaymentGatewayError('stripe', response.status, 'Internal failure')
    return {'status': 'success', 'txn_id': response.txn_id}
Output
>>> charge_gateway('user_42', 150.00)
Traceback (most recent call last):
...
RateLimitError: stripe returned 429: Retry after 30s
Senior Shortcut:
Before writing a custom exception, ask: 'Will the caller catch this differently than ValueError?' If no, use ValueError.
Key Takeaway
Custom exceptions for semantic distinctions that change handling. Not for vanity. Not for 'more descriptive names'.

Real-World Patterns: What Senior Devs Actually Do With try-except

Junior devs write try blocks that span entire functions. Senior devs wrap the smallest possible unit of work. The rule: if you can't predict which line raises, your except is a lie. In production, you see patterns like specific retry logic for transient failures — database timeouts, network blips — and immediate re-raise for anything permanent. Anything that touches I/O gets a try. Anything that processes memory gets assertions, not try. The cost of an incorrect except is a silent data corruption. The cost of a missing try is a stack trace. Know which you can afford. Real code uses context managers for 80% of cleanup, and raw try-finally only when you absolutely need to control resource release order — like file descriptors in a tight loop where you can't afford a context manager's __exit__ overhead.

RetryWithExponentialBackoff.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// io.thecodeforge — python tutorial

import time
import requests
from socket import timeout

MAX_RETRIES = 3

def fetch_user_data(user_id: str) -> dict:
    for attempt in range(1, MAX_RETRIES + 1):
        try:
            resp = requests.get(f"https://api.users/v2/{user_id}", timeout=2.0)
            resp.raise_for_status()
            return resp.json()
        except (requests.ConnectionError, timeout) as e:
            if attempt == MAX_RETRIES:
                raise  # give up, let caller decide
            time.sleep(2 ** attempt)  # 2, 4, 8 seconds
        except requests.HTTPError:
            raise  # 4xx or 5xx — no point retrying
Output
// No output — pattern is for production reliability. Retries only on transient failures.
Production Trap:
Don't retry on HTTP 4xx errors. The server isn't going to accept a bad request if you ask again. Only retry network blips and 5xx statuses.
Key Takeaway
Wrap the smallest unit of work. Retry transient failures with backoff. Re-raise permanent failures immediately.

Catch Multiple Exceptions: Don't Be That Dev Who Uses Bare except

A bare except catches KeyboardInterrupt. It catches SystemExit. It catches memory errors that leave your process in a zombie state. You are not smarter than the interpreter. If you catch everything, you own everything — including the bug you just hid. tuple except clauses are your friend: except (ValueError, TypeError) as e. Be explicit about what you expect to fail. If you need to log-and-re-raise, do it with raise (no argument) to preserve the stack. The only acceptable use of bare except is in a top-level event loop that must never die — and even then you log and call sys.exit(1). Production code reviews reject bare except like a SQL injection. Get specific.

ExplicitExcept.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// io.thecodeforge — python tutorial

def parse_and_store(data: str):
    try:
        value = int(data.strip())
    except (ValueError, TypeError) as e:
        # Only these two — input parsing failures
        print(f"invalid data: {e}")
        return None

    try:
        store_to_db(value)
    except ConnectionError:
        # Infrastructure failure — log, don't silence
        logger.error("db connection failed")
        raise
Output
invalid data: could not convert string to float: 'three'
Senior Shortcut:
Use except (ValueError, TypeError, KeyError) as a tuple instead of catching Exception. It documents exactly what you expect to fail and makes code review instant.
Key Takeaway
Never use bare except. Catch the specific exceptions you can handle. Re-raise what you can't fix.
● Production incidentPOST-MORTEMseverity: high

The Silent Connection Leak: How a NameError Inside finally Killed a 3AM Pager Rotation

Symptom
Service becomes unresponsive every ~72 hours; connection pool exhausts; clients see upstream connect error.
Assumption
Database connections are properly released because finally is used in every function.
Root cause
The function opened a database connection inside try and assigned it to a local variable. A separate except branch re-raised the original exception. The finally block then attempted to call connection.close() — but because the assignment line itself had failed in one specific error path, the name 'connection' was never bound. That NameError inside finally became the active exception, replacing and permanently discarding the original database error. With the original exception gone, the monitoring system saw nothing. The leaked connection was never returned to the pool. Over 72 hours, the pool exhausted itself.
Fix
Always assign the resource variable to None before the try block. Guard the cleanup call in finally with 'if connection is not None'. Better still: use a context manager — the with statement guarantees cleanup even if an exception occurs during resource acquisition itself, and it cannot raise a NameError because resource binding is handled by the protocol.
Key lesson
  • finally is not immune to exceptions inside itself — a NameError or any other error in cleanup becomes the active exception and permanently discards whatever was originally in flight.
  • Always initialise resource variables to None before try and guard every usage in finally with an explicit 'is not None' check.
  • Prefer context managers (with blocks) over manual try-finally for resource management — they eliminate this entire class of bug by design.
Production debug guideSymptom-to-action table for common try-except-finally bugs4 entries
Symptom · 01
Application crashes with an unhandled exception that was expected to be caught
Fix
Check that the exception class name is spelled correctly — except ValuError silently creates a block that never matches. Also verify the exception is actually a subclass of what you're catching; catching Exception will not catch BaseException subclasses like KeyboardInterrupt or SystemExit.
Symptom · 02
Resource leak (file handles, connections) despite having finally blocks
Fix
Verify the resource variable is initialised to None before try. Check whether the finally block itself raises an exception — a failed logging call or a NameError inside finally discards the cleanup path entirely. Use 'if resource is not None' guards throughout.
Symptom · 03
Exception traceback shows no line number in __cause__ chain
Fix
Missing raise X from Y — the original exception context is lost. Add raise NewException('message') from original_exception to preserve the full chain. Without it, the original traceback disappears and debugging becomes guesswork.
Symptom · 04
Unexpected None returned from function that should return data
Fix
An except block is likely catching an exception and falling through without an explicit return, so the function returns None implicitly. Check whether an else block is missing and post-success logic has been placed inside try — a different exception from that logic gets caught by your except handlers, which return None or a default instead of surfacing the real error.
★ Quick Debug Cheat Sheet for Exception Handling PitfallsUse these checks when your try-except-finally logic isn't behaving as expected
finally block not executing
Immediate action
Add a print or log line at the very first line of finally to confirm it is or isn't reached. The block will not run if the process is killed with SIGKILL or os._exit() is called — both bypass the Python runtime entirely. sys.exit() is different: it raises SystemExit, which is a proper exception, and finally will run normally.
Commands
python -c 'try: raise RuntimeError finally: print("finally ran")'
grep -rn 'os._exit' src/ — locate any os._exit() calls that hard-kill the process without giving finally a chance to run.
Fix now
Replace os._exit() with raise SystemExit() wherever cleanup matters. If the process is being killed externally by SIGKILL (e.g., OOM killer), there is no Python-level solution — the OS does not give the interpreter any notice.
Exception silently suppressed without being logged+
Immediate action
Search the codebase for any except block whose body is only pass or only a comment. Every except block that does not re-raise must contain at minimum a log statement.
Commands
grep -rn 'except' src/ | grep -v 'raise\|log\|print'
Audit logger configuration — a logger.exception() call is useless if the handler's log level is set higher than ERROR or the handler list is empty.
Fix now
Replace every except: pass with: logger.exception('Unexpected error in <context>') followed by raise. If you genuinely cannot re-raise, add a metrics counter so at least the frequency is visible.
Different exception raised than expected+
Immediate action
Print the full traceback chain from inside the except block using traceback.print_exc() — this shows both the current exception and any chained cause. Read from the bottom up: the bottom frame is where it actually went wrong.
Commands
python -c "import traceback; try: 1/0 except ZeroDivisionError: traceback.print_exc()"
Check for bare raise inside except blocks — a naked raise re-raises the current exception correctly, but raise ValueError('new') without from loses the original exception's context entirely.
Fix now
Always use raise NewException('message') from original_exception when raising a different exception inside except. This binds the original to __cause__ and Python prints both in the traceback.
try-except-finally vs. Context Manager
Scenariotry-except-finallyContext Manager (with)
Acquiring and releasing a file handleWorks but error-prone: must ensure finally closes filePreferred: with statement guarantees __exit__
Database connection with commit/rollbackManual: commit on success in try, rollback in except, close in finallyAuto: __exit__ handles both, or use contextmanager
Lock management in threadingManual acquire/release, risk of deadlock if finally not reachedwith lock: ensures release even on exception
Custom cleanup logic with statePossible but verboseClass-based context manager gives clean enter/exit
Multiple resourcesCan nest try blocks -> deep, error-proneNested with statements or ExitStack from contextlib
suppressing specific exceptionsnot designed for this__exit__ can return True to suppress exception

Key takeaways

1
Keep try blocks narrow and always use else for post-success processing.
2
Never return a value from finally
it suppresses exceptions silently.
3
Always chain exceptions with 'raise X from Y' to preserve the original traceback.
4
Prefer context managers over manual try-finally for resource management.
5
Bare except
pass is the most dangerous pattern — log and re-raise instead.

Common mistakes to avoid

4 patterns
×

Using bare except: pass

Symptom
Silent failures — no log, no metric. KeyboardInterrupt and SystemExit are also swallowed, making the process unkillable.
Fix
Replace with specific exception handler. Always log and re-raise if you cannot handle the exception meaningfully.
×

Returning a value from finally

Symptom
Exceptions silently suppressed. The function returns the finally value instead of propagating the error.
Fix
Never use return in finally. Reserve finally for side-effect cleanup only.
×

Not using else block for post-success logic

Symptom
Exceptions from processing code are caught by the wrong except handler, causing misleading error messages and delayed bug discovery.
Fix
Move all post-success processing into else. Keep try block as narrow as possible.
×

Catching too broadly with except Exception without logging

Symptom
Hides severe errors. Monitoring sees no alerts; debugging requires reconstructing events from memory.
Fix
Log at critical level with exc_info=True. If you cannot handle it, re-raise. Add metrics counters to track frequency.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Explain the difference between else and finally in Python's try-except-e...
Q02SENIOR
What happens if an exception is raised inside a finally block?
Q03SENIOR
How does exception chaining work in Python? When would you use 'raise X ...
Q01 of 03JUNIOR

Explain the difference between else and finally in Python's try-except-else-finally.

ANSWER
else runs only when try completes without an exception — it's for success-only logic. finally runs unconditionally after everything else, even if an exception was raised or a return was hit. finally is for cleanup (closing resources). else prevents post-success exceptions from being caught by the wrong except handler. For example, if you parse data after a successful IO operation, put the parsing in else so a KeyError in parsing doesn't get caught by the IO exception handler.
FAQ · 3 QUESTIONS

Frequently Asked Questions

01
When should I use the else block?
02
What is the difference between 'except:' and 'except Exception:'?
03
Can I have multiple except blocks?
N
Naren Founder & Principal Engineer

20+ years shipping production Python across data and backend systems. Notes here come from systems that actually shipped.

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

That's Exception Handling. Mark it forged?

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

Previous
Exception Handling in Python
2 / 5 · Exception Handling
Next
Custom Exceptions in Python