~/TechPurAI
~/tutorials/python-exception-handling
beginner·standalone·6 min read

Exception handling in Python, done properly

Updated Aug 31, 2026Python

Wrapping code in try/except is easy. Wrapping it in a way that actually helps whoever reads the traceback six months from now is the part most tutorials skip. This one covers all four blocks and the two habits that quietly make error handling worse than having none at all.

The basic shape

python
try:
    amount = float(input("Amount: "))
except ValueError:
    print("That's not a number.")

Python runs the try block. If a ValueError is raised anywhere inside it, execution jumps straight to except ValueError and the rest of the try block is skipped. If nothing goes wrong, the except block never runs at all.

Catch the exception you expect, not everything

python
try:
    amount = float(input("Amount: "))
except:
    print("Something went wrong.")

A bare except: catches every exception — including KeyboardInterrupt when someone presses Ctrl+C, and typos in your own code that raise NameError or AttributeError. Both get silently reported as "something went wrong," which turns a five-second fix into an afternoon of guessing.

Common mistake

Catch the specific exception type you know how to handle: except ValueError:, except FileNotFoundError:. If you genuinely need a catch-all for logging purposes, use except Exception: — it still lets KeyboardInterrupt and SystemExit through, since those inherit from BaseException rather than Exception.

Catching more than one type

A single block can handle several related exceptions:

python
try:
    with open("config.json") as f:
        data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError) as err:
    print(f"Couldn't load config: {err}")

The parentheses group the exception types. as err binds the actual exception object to a name, so str(err) or err.args can go into your error message instead of a generic one — the difference between "couldn't load config" and "couldn't load config: line 4 column 1 (char 42)."

else — the block everyone forgets

python
try:
    response = fetch_data()
except ConnectionError:
    print("Network request failed.")
else:
    save_to_cache(response)

else runs only when the try block completes with no exception. Putting save_to_cache(response) inside the try block instead would technically work too — but then an exception raised by save_to_cache itself would get caught by except ConnectionError, misreporting a caching bug as a network failure. else keeps "the risky operation" and "what happens if it succeeds" clearly separated.

finally — cleanup that always runs

python
lock = acquire_lock()
try:
    do_work()
finally:
    lock.release()

finally runs no matter what — whether the try block succeeds, raises a handled exception, or raises one that isn't caught at all. It's the right place for cleanup that must happen regardless of outcome: closing a file, releasing a lock, rolling back a transaction. If do_work() raises, lock.release() still runs before the exception propagates up.

Files and locks usually don't need this

with open(...) as f: already calls finally-style cleanup for you — that's what a context manager is for. Reach for an explicit finally when you're managing something a with block doesn't cover, like the lock example above.

Adding context when you re-raise

Sometimes the right move isn't to handle an exception, just to add information before it keeps propagating:

python
try:
    user = load_user(user_id)
except KeyError as err:
    raise RuntimeError(f"No user found for id {user_id}") from err

from err chains the two exceptions together in the traceback, so whoever reads it sees both "here's the error I raised" and "here's what originally caused it" — instead of the original KeyError disappearing and leaving only a confusing RuntimeError with no clue where it came from.

What good error handling looks like

python
import logging

logger = logging.getLogger(__name__)

def load_config(path):
    try:
        with open(path) as f:
            return json.load(f)
    except FileNotFoundError:
        logger.warning("No config file at %s — using defaults.", path)
        return DEFAULT_CONFIG
    except json.JSONDecodeError as err:
        raise RuntimeError(f"Config file at {path} is not valid JSON") from err

Two different failures, two different responses: a missing file is expected and recoverable, so it falls back quietly. Malformed JSON is a real bug in the config file, so it's re-raised with enough context to actually fix it — not caught, printed, and ignored. That distinction, more than the syntax itself, is what separates error handling from error hiding.

logging.warning instead of print here isn't a stylistic preference — a print statement's output is easy to lose in a busy terminal or a production log stream with no severity level, no timestamp, and no way to filter it out selectively. logger.warning(...) carries a severity level that a deployed application can filter, route to a monitoring service, or silence independently of logger.error(...) calls elsewhere in the same codebase.

Defining a custom exception for your own error cases

The built-in exception types (ValueError, KeyError, FileNotFoundError) cover generic situations. When a failure is specific to your own application's logic, a custom exception class makes that distinction explicit to whoever catches it later:

python
class InsufficientFundsError(Exception):
    def __init__(self, requested: float, available: float):
        self.requested = requested
        self.available = available
        super().__init__(f"Requested {requested}, only {available} available.")

def withdraw(account, amount: float):
    if amount > account.balance:
        raise InsufficientFundsError(amount, account.balance)
    account.balance -= amount
python
try:
    withdraw(account, 500)
except InsufficientFundsError as err:
    print(f"Can't complete withdrawal: needed {err.requested}, had {err.available}")

except InsufficientFundsError catches exactly this failure and nothing else — a caller that only wants to handle this specific business rule isn't forced to write a broader except ValueError that would also swallow an unrelated ValueError from somewhere else in the same try block. The custom class also carries structured data (requested, available) on the exception object itself, rather than requiring the caller to parse those numbers back out of a formatted string.

FAQ

Should every function that can fail define its own exception class? No — a custom exception earns its place when a caller genuinely needs to handle that specific failure differently from Python's built-in exception types. A function that can only fail with a plain ValueError in a way any caller would handle the same way doesn't need a custom class on top of it.

Is it ever correct to catch an exception and do nothing? Rarely, and it should be a deliberate except SpecificError: pass with a comment explaining why silence is correct — not a bare except: that also silently swallows unrelated bugs. An empty except block with no comment is close to indistinguishable from a mistake when someone else reads it later.

When should I use logging.exception instead of logging.error? logging.exception (called from inside an except block) automatically includes the full traceback in the log output — use it when you're logging an exception you just caught. logging.error is for a plain error message with no associated exception object.

Does re-raising with from err change what the caller has to catch? No — the caller still catches the outer exception type (RuntimeError in the config example above). from err only changes what's shown in the traceback when it isn't caught, chaining the original cause underneath the new exception for whoever reads the crash log.

VK

Vijay Kumar

Founder of TechPurAI — writing hands-on tutorials and honest tool breakdowns.

LinkedIn ↗
← previous in language-featuresVirtual environments and pip, explainednext in language-features →Python compiler vs. interpreter: what actually happens when you run a .py file