Error Handling in Python: try, except, else, finally
A practical guide to Python exception handling — try, except, else, finally, raising and re-raising, custom exceptions, and the habits that make error handling helpful.
What you'll learn
- ✓How try/except catches exceptions
- ✓The role of else and finally
- ✓How to raise and re-raise exceptions
- ✓When to define a custom exception class
- ✓The exception hierarchy and how to catch the right things
- ✓Common error-handling antipatterns to avoid
Prerequisites
- •Comfortable with functions — see Functions
Things will go wrong: files won’t exist, networks will drop, users will type “abc” into the age field. Python’s exception system lets you describe what should happen and handle the unexpected separately. Done well, exception handling makes your programs both shorter and more robust. Done poorly, it hides bugs and makes them harder to find. This post covers both sides.
Exceptions, briefly
When something goes wrong, Python raises an exception — an object that describes what happened. Unhandled, an exception travels up the call stack and stops the program with a traceback.
print(int("abc"))
# ValueError: invalid literal for int() with base 10: 'abc'
Every exception has a type (ValueError here) and usually a message. Common ones include ValueError, TypeError, KeyError, IndexError, FileNotFoundError, ZeroDivisionError, and AttributeError.
try and except
To handle an exception, wrap the risky code in try and write an except block for the type(s) you can recover from:
def parse_age(raw):
try:
return int(raw)
except ValueError:
return None
print(parse_age("30")) # 30
print(parse_age("abc")) # None
The try block runs to completion if no exception occurs. If one matches the except, control jumps there and the rest of the try block is skipped.
You can name the exception object with as:
try:
int("abc")
except ValueError as e:
print(f"Could not parse: {e}")
# Could not parse: invalid literal for int() with base 10: 'abc'
You can also catch multiple types with a tuple:
try:
do_something()
except (ValueError, TypeError) as e:
print(f"Bad input: {e}")
Or write several except blocks for different responses:
try:
do_something()
except FileNotFoundError:
print("File missing — using defaults.")
except PermissionError:
print("No permission — check file mode.")
Python tries each except in order and uses the first that matches the exception class (or a superclass).
Catch specific exceptions
A bare except: (or except Exception:) catches almost everything, including bugs in your own code. That is rarely what you want:
# Bad
try:
process(data)
except Exception:
print("something went wrong")
# Good
try:
process(data)
except (ValueError, KeyError) as e:
print(f"bad data: {e}")
Catch the specific failures you can actually handle. Let everything else propagate so it produces a useful traceback. A program that silently swallows every error is harder to debug than one that crashes loudly.
else and finally
A try statement can have two more clauses.
else runs when the try block finished without raising. Use it to separate “the recovery path” from “the success path”:
def load_config(path):
try:
f = open(path)
except FileNotFoundError:
print("No config — using defaults.")
return {}
else:
with f:
return parse(f.read())
Keeping parse outside the try means you don’t accidentally catch ValueErrors from inside it.
finally runs no matter what — exception or not, with or without an except matching. Use it for cleanup that must happen even on failure:
def write_audit(message):
log = open("audit.log", "a")
try:
log.write(message + "\n")
finally:
log.close()
In practice, with statements (covered in File I/O) handle most cleanup more cleanly. finally is still useful for non-file resources or for transactional rollback logic.
The full ordering is try → optional excepts → optional else → optional finally. At least one of except and finally must be present.
Try it yourself. Write a function safe_divide(a, b) that returns a / b, or None if b is zero. Use try/except with ZeroDivisionError. Then write safe_index(items, i) that returns items[i] or None if i is out of range, catching IndexError.
Raising exceptions
You raise an exception with the raise keyword:
def withdraw(balance, amount):
if amount <= 0:
raise ValueError("amount must be positive")
if amount > balance:
raise ValueError("insufficient funds")
return balance - amount
Raising early at the boundary of your function makes the rest of the code free to assume valid inputs. Pick the most appropriate built-in exception type:
ValueError— argument has the right type but wrong valueTypeError— argument has the wrong typeKeyError/IndexError— missing key or out-of-range indexFileNotFoundError— file not presentNotImplementedError— a stub you have not finished
If none fit, define your own (see below) before reaching for Exception itself.
Re-raising
Sometimes you want to catch an exception, do something (log it, clean up), then let it propagate. A bare raise re-raises the current exception:
def process_record(record):
try:
validate(record)
save(record)
except ValueError:
log.error("validation failed for %r", record)
raise
To raise a new exception that wraps the original, use raise ... from:
def load_user(user_id):
try:
return database.get(user_id)
except KeyError as e:
raise LookupError(f"user {user_id} not found") from e
The from clause records the original cause in the traceback. It is the right tool when you want a high-level message but still want the low-level detail available.
Custom exception classes
Define your own exception types when you want callers to catch them specifically. Inherit from Exception (or a more specific subclass):
class ConfigError(Exception):
"""Raised when configuration is invalid."""
class MissingKeyError(ConfigError):
"""Raised when a required config key is absent."""
The body of the class can be empty — the docstring alone is fine. With a hierarchy, callers can be as specific or as general as they need:
try:
load_config()
except MissingKeyError:
...
except ConfigError:
...
This is how the standard library is structured: FileNotFoundError, PermissionError, and so on all inherit from OSError.
The exception hierarchy
A simplified view of the exception tree:
BaseException
├── SystemExit
├── KeyboardInterrupt
└── Exception
├── ArithmeticError
│ └── ZeroDivisionError
├── LookupError
│ ├── IndexError
│ └── KeyError
├── ValueError
├── TypeError
└── OSError
├── FileNotFoundError
└── PermissionError
Two rules follow from this:
- Catch
Exception, notBaseException.BaseExceptionincludesKeyboardInterruptandSystemExit. Catching those usually means your program ignores Ctrl+C. - Catching a parent catches all children.
except OSErrorcatches bothFileNotFoundErrorandPermissionError.
EAFP and LBYL
Python culture has names for two error-handling philosophies:
- LBYL — “Look Before You Leap.” Check first, act second.
- EAFP — “Easier to Ask Forgiveness than Permission.” Try the action, handle failure.
# LBYL
if "name" in user:
print(user["name"])
# EAFP
try:
print(user["name"])
except KeyError:
pass
Python tends to favour EAFP because the alternative often has race conditions (the file existed when you checked but not when you opened it). For dictionary access specifically, .get() is even cleaner — see Python Dictionaries.
Neither philosophy is universally right. Use whichever expresses your intent most clearly.
Try it yourself. Define a BankError base class and two subclasses InsufficientFundsError and AccountFrozenError. Write a withdraw(balance, amount, frozen) function that raises the right subclass. Then catch them separately and print a tailored message for each.
A worked example
A small loader that parses integer values from a config file, with layered error handling:
class ConfigError(Exception):
"""Base class for configuration problems."""
class MissingFieldError(ConfigError):
pass
class InvalidFieldError(ConfigError):
pass
def load_port(config: dict) -> int:
try:
raw = config["port"]
except KeyError as e:
raise MissingFieldError("'port' is required") from e
try:
port = int(raw)
except (TypeError, ValueError) as e:
raise InvalidFieldError(f"'port' must be an integer, got {raw!r}") from e
if not (1 <= port <= 65535):
raise InvalidFieldError(f"'port' must be 1-65535, got {port}")
return port
def main():
for cfg in [{"port": "8080"}, {}, {"port": "abc"}, {"port": 70000}]:
try:
print("port:", load_port(cfg))
except ConfigError as e:
print("config error:", e)
main()
# port: 8080
# config error: 'port' is required
# config error: 'port' must be an integer, got 'abc'
# config error: 'port' must be 1-65535, got 70000
Each try block is narrow and catches one specific failure. main only has to know about ConfigError — the most general type — but every error message carries a precise reason.
Recap
You now know:
- Exceptions describe what went wrong; unhandled, they stop the program
try/exceptlets you recover from specific exceptionselseruns only on success;finallyruns always — use it for cleanup- Catch specific exceptions, not bare
Exception raiseproduces an exception; bareraisere-raises the current oneraise X() from ewraps a lower-level error in a higher-level one- Define custom exception classes to let callers catch your errors precisely
- Python culture leans toward EAFP, but use whichever reads more clearly
Next steps
The next post applies what you have learned about errors in one of the places they show up most: working with files. We will cover reading, writing, and the with statement that closes files safely no matter what.
→ Next: Reading and Writing Files in Python
Questions or feedback? Email codeloomdevv@gmail.com.