Exception handling in Python, done properly
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
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
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.
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:
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
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
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.
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:
try:
user = load_user(user_id)
except KeyError as err:
raise RuntimeError(f"No user found for id {user_id}") from errfrom 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
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 errTwo 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:
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 -= amounttry:
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.