Python Exceptions and Custom Errors
AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.
A missing file, a dropped connection, malformed input: exceptions are how Python tells you that the normal path did not happen. Handling them well means the program fails in a way you can diagnose, instead of a stack trace from a line that looks unrelated.
Environment
Examples are verified on Python 3.13 with no extra dependencies.
The four blocks
try:
value = int(input("number: "))
except ValueError:
print("that is not a number")
else:
print(f"got {value}") # runs only if try succeeded
finally:
print("cleanup") # always runs
Execution order:
trycompletes →else→finally.tryraises and anexceptmatches → that block →finally.tryraises and nothing matches →finally→ the exception propagates.
finally is for cleanup. With with handling most resource release, you rarely need it.
Catch what you can handle
try:
with open("config.json", encoding="utf-8") as f:
config = json.load(f)
except FileNotFoundError:
config = {} # expected on first run
except json.JSONDecodeError as e:
raise ConfigError("invalid config") from e # cannot continue here
Rules that keep this readable:
- Catch the narrowest exception you can actually recover from.
- Order handlers from specific to general;
ValueErrorbeforeException. as ebinds the exception object;str(e)is its message.- To log and re-raise, use a bare
raiseso the original traceback survives.
else is the underused part: it keeps the “assume nothing failed” logic out of the try, so a later bug is not silently swallowed by an unrelated handler.
Raising exceptions
def divide(a, b):
if b == 0:
raise ValueError("b must not be zero")
return a / b
Built-in types already cover most validation:
| Situation | Exception |
|---|---|
| Wrong value | ValueError |
| Wrong type | TypeError |
| Missing key | KeyError |
| Index out of range | IndexError |
| Missing file | FileNotFoundError |
| Missing attribute | AttributeError |
Include the offending value in the message. raise ValueError(f"bad port: {port}") is worth ten seconds of typing when the log arrives at 3 a.m.
Chaining with raise ... from
When you translate a low-level failure into a domain error, keep the cause:
class ConfigError(Exception):
pass
try:
config = load_config()
except OSError as e:
raise ConfigError("could not load config") from e
The traceback then shows both:
ConfigError: could not load config
The above exception was the direct cause of the following exception:
FileNotFoundError: [Errno 2] No such file or directory: 'config.json'
from None suppresses the original exception entirely. Use it only when the cause is genuinely noise, such as an implementation detail you are deliberately hiding from callers.
Defining custom exceptions
Business failures deserve names so callers can branch on them:
class AppError(Exception):
"""Base class for application errors."""
class ConfigError(AppError):
pass
class ValidationError(AppError):
def __init__(self, field: str, message: str):
super().__init__(f"{field}: {message}")
self.field = field
Usage:
try:
save(user)
except ValidationError as e:
print(f"field {e.field} failed: {e}")
except AppError:
print("other application error")
Conventions:
- Inherit from
Exception, notBaseException; the latter includesKeyboardInterruptandSystemExit. - One project-level base class lets an outer layer handle “any error of ours” in one place.
- End names with
Error. - Do not add a custom
__str__that drops the message you passed tosuper().__init__.
finally and return do not mix
def bad():
try:
return "try"
finally:
return "finally" # discards "try" and swallows exceptions
A return inside finally overrides the try result and suppresses any in-flight exception. Never return from finally; manage state before entering try.
Common mistakes
1. Bare except or except Exception: pass
try:
risky()
except Exception:
pass # the bug is now invisible
At minimum log it and re-raise:
import logging
try:
risky()
except Exception:
logging.exception("risky failed") # includes the traceback
raise
2. Using exceptions for ordinary flow
try:
value = d["key"]
except KeyError:
value = None
value = d.get("key") says the same thing without exception machinery.
3. Wrong handler order
except Exception placed first makes every later handler dead code. Linters flag this; do not rely on them catching it during review.
4. Catching inside a loop
One broad handler around a batch aborts all remaining items or hides which one failed. Catch per item, log the index, keep going, or let it fail on the first error deliberately.
5. Losing the traceback
except OSError as e:
raise ConfigError(str(e)) # original traceback lost
Use from e, or let the original propagate.
6. Assuming str(e) is always useful
Some exceptions have empty messages. Log the type and the traceback, not just the message.
7. Raising in __init__ of exceptions incorrectly
Custom exceptions that do extra I/O or validation are fragile: raising an exception can fail with another exception, producing a confusing double traceback.
Summary
try/except/else/finally:elsefor success-only logic,finallyfor cleanup.- Catch only what you can handle; order handlers specific to general.
- Use
raise ... from ewhen translating errors, bareraisewhen re-throwing. - Custom exceptions:
Exceptionsubclass, project base class,Errorsuffix. - Never
returnfromfinally; never swallow exceptions silently.
That completes the Python basics series: values, control flow, strings, containers, functions, files, and errors.