In the previous tutorial, we learned about dataclasses and Pydantic. Now let’s learn about error handling — how to write code that fails gracefully instead of crashing.
Every program encounters errors. Files go missing, networks fail, users enter bad data. Good error handling is the difference between a program that crashes with a confusing traceback and one that tells the user what went wrong and how to fix it. By the end of this tutorial, you will know how to catch errors, create custom exceptions, and choose the right error handling pattern.
Basic try/except
Use try/except to catch errors and handle them:
def safe_divide(a: float, b: float) -> float | None:
"""Divide a by b, returning None if b is zero."""
try:
return a / b
except ZeroDivisionError:
return None
print(safe_divide(10, 3)) # 3.333...
print(safe_divide(10, 0)) # None (instead of crashing)
The try block runs the code. If an error occurs, Python jumps to the matching except block. If no error occurs, the except block is skipped.
Without try/except, dividing by zero would crash the program with ZeroDivisionError: division by zero. With error handling, we return None instead.
Catching Specific Exceptions
Always catch specific exceptions. Never use bare except: — it catches everything, including keyboard interrupts (Ctrl+C) and system exits.
# GOOD — catches only ValueError
def parse_int(value: str) -> int | None:
try:
return int(value)
except ValueError:
return None
# GOOD — catches multiple specific types
def get_item(data: list | dict, key):
try:
return data[key]
except (IndexError, KeyError, TypeError):
return None
# BAD — catches everything! Never do this.
try:
result = do_something()
except:
pass # Swallows ALL errors, even Ctrl+C
If you are not sure which exception to catch, start without try/except. Let the error happen, read the traceback, and then add a specific except clause.
Common built-in exceptions:
| Exception | When it happens |
|---|---|
ValueError | Wrong value (e.g., int("abc")) |
TypeError | Wrong type (e.g., len(42)) |
KeyError | Missing dict key (e.g., d["missing"]) |
IndexError | List index out of range (e.g., lst[100]) |
FileNotFoundError | File does not exist |
ZeroDivisionError | Division by zero |
AttributeError | Object has no such attribute |
PermissionError | No permission to access a file |
ConnectionError | Network connection failed |
All built-in exceptions inherit from BaseException. Most of them inherit from Exception. You should only catch exceptions that inherit from Exception — never catch BaseException directly.
try/except/else/finally
The full try statement has four parts:
def read_number(value: str) -> dict:
"""Parse a number string and return status info."""
result = {"success": False, "value": None, "cleaned_up": False}
try:
number = float(value)
except ValueError:
result["error"] = f"Cannot parse '{value}' as a number"
else:
# Runs only if NO exception occurred
result["success"] = True
result["value"] = number
finally:
# Runs ALWAYS, even after an exception or a return
result["cleaned_up"] = True
return result
print(read_number("3.14"))
# {'success': True, 'value': 3.14, 'cleaned_up': True}
print(read_number("abc"))
# {'success': False, 'value': None, 'cleaned_up': True, 'error': "Cannot parse 'abc' as a number"}
Here is what each part does:
try— code that might failexcept— handles the errorelse— runs only if try succeeds (no exception). Put the “happy path” code here.finally— runs always, even after an exception or a return statement. Use it for cleanup.
The else block is optional but useful. It separates error handling from success logic. Without else, you might accidentally catch exceptions from your success code:
# Without else — process_number errors are also caught!
try:
number = float(value)
result = process_number(number) # If this fails, ValueError is caught too
except ValueError:
print("Bad input")
# With else — only float() errors are caught
try:
number = float(value)
except ValueError:
print("Bad input")
else:
result = process_number(number) # Errors here are NOT caught
The finally block is useful for closing files, database connections, or network sockets. It runs even if the function returns early from inside the try block.
Custom Exceptions
Create your own exceptions by inheriting from Exception. This lets you add extra information and create a hierarchy of error types:
class AppError(Exception):
"""Base exception for our application."""
pass
class NotFoundError(AppError):
"""Raised when a resource is not found."""
def __init__(self, resource: str, resource_id: str) -> None:
self.resource = resource
self.resource_id = resource_id
super().__init__(f"{resource} with id '{resource_id}' not found.")
class ValidationError(AppError):
"""Raised when input validation fails."""
def __init__(self, field: str, message: str) -> None:
self.field = field
self.message = message
super().__init__(f"Validation error on '{field}': {message}")
Custom exceptions carry extra information:
try:
raise NotFoundError("User", "42")
except NotFoundError as e:
print(e) # User with id '42' not found.
print(e.resource) # User
print(e.resource_id) # 42
The key design pattern: create a base exception for your application (AppError). Then create specific exceptions that inherit from it. This gives callers flexibility:
# Catch all application errors
except AppError as e:
log_error(e)
# Or catch specific errors
except NotFoundError as e:
return 404
except ValidationError as e:
return 400
Using Custom Exceptions in Practice
Here is a realistic example:
_users_db = {
"1": {"name": "Alex", "email": "alex@example.com"},
"2": {"name": "Sam", "email": "sam@example.com"},
}
def get_user(user_id: str) -> dict:
"""Get a user by ID. Raises NotFoundError if not found."""
if user_id not in _users_db:
raise NotFoundError("User", user_id)
return _users_db[user_id]
def validate_email(email: str) -> str:
"""Validate an email address. Raises ValidationError if invalid."""
if not email:
raise ValidationError("email", "Email cannot be empty.")
if "@" not in email:
raise ValidationError("email", "Email must contain @.")
return email.lower()
Using these functions:
# Happy path
user = get_user("1")
print(user["name"]) # Alex
# Error path with specific handling
try:
user = get_user("99")
except NotFoundError as e:
print(f"Not found: {e.resource} {e.resource_id}")
# Validation
try:
email = validate_email("invalid")
except ValidationError as e:
print(f"Field '{e.field}': {e.message}")
This pattern is common in web APIs: the route handler calls service functions that raise custom exceptions, and middleware converts those exceptions into HTTP responses.
Exception Chaining: raise … from
When you catch one exception and raise another, use raise ... from to preserve the original error. This creates a chain that helps with debugging:
def load_config(data: dict) -> dict:
"""Load config from a dict. Chains exceptions for context."""
try:
name = data["name"]
port = int(data["port"])
except KeyError as e:
raise AppError(f"Missing config key: {e}") from e
except ValueError as e:
raise AppError(f"Invalid config value: {e}") from e
return {"name": name, "port": port}
The from e connects the new exception to the original. When you see the traceback, it shows both:
try:
load_config({"name": "app"})
except AppError as e:
print(e) # Missing config key: 'port'
print(e.__cause__) # KeyError('port')
Without from, the traceback shows “During handling of the above exception, another exception occurred” which is confusing. With from, it shows “The above exception was the direct cause of the following exception” which is clear.
Exception Groups (Python 3.11+)
Sometimes you want to collect multiple errors at once instead of stopping at the first one. This is common in form validation. Use ExceptionGroup:
def validate_form(data: dict) -> dict:
"""Validate a form. Collects all errors at once."""
errors = []
if not data.get("name", "").strip():
errors.append(ValidationError("name", "Name is required."))
email = data.get("email", "")
if not email:
errors.append(ValidationError("email", "Email is required."))
elif "@" not in email:
errors.append(ValidationError("email", "Email must contain @."))
age = data.get("age")
if age is not None and (not isinstance(age, int) or age < 0):
errors.append(ValidationError("age", "Age must be a positive integer."))
if errors:
raise ExceptionGroup("Form validation failed", errors)
return data
Catch exception groups like this:
try:
validate_form({"name": "", "email": "invalid", "age": -5})
except ExceptionGroup as eg:
for error in eg.exceptions:
print(f" - {error.field}: {error.message}")
# - name: Name is required.
# - email: Email must contain @.
# - age: Age must be a positive integer.
This is much better than stopping at the first error. Users see all problems at once and can fix them in one go.
Python 3.11 also added except* for catching specific exception types within a group, but the basic pattern above covers most use cases.
contextlib.suppress
For simple cases where you just want to ignore a specific exception, use contextlib.suppress. It is cleaner than a try/except that does nothing:
import contextlib
# Instead of this:
try:
user = get_user("99")
except NotFoundError:
user = None
# You can write this:
user = None
with contextlib.suppress(NotFoundError):
user = get_user("99")
Both versions do the same thing. Use suppress only for simple cases where the except block would be empty or just set a default value. For anything more complex, use try/except.
LBYL vs EAFP
Python has two error handling philosophies:
LBYL — Look Before You Leap: Check for errors before they happen.
def lbyl_get_value(data: dict, key: str, default=None):
if key in data: # Check first
return data[key] # Then access
return default
EAFP — Easier to Ask Forgiveness than Permission: Try first, handle errors after.
def eafp_get_value(data: dict, key: str, default=None):
try:
return data[key] # Try first
except KeyError:
return default # Handle error
Python strongly prefers EAFP. Here is why:
- It is often faster — in the happy path (no error), try/except has almost zero overhead. LBYL always runs the check.
- It avoids race conditions — between checking and accessing, the state might change (e.g., a file could be deleted).
- It is more Pythonic — the Python community convention.
Use LBYL only when the check is much simpler than catching the exception, or when the exception is very expensive.
When to Use Each Pattern
| Pattern | Best for |
|---|---|
try/except | Handling expected errors gracefully |
try/except/else | Separating error handling from success logic |
try/finally | Cleanup code that must always run |
| Custom exceptions | Libraries and applications with specific error types |
raise ... from | Wrapping low-level errors with high-level context |
ExceptionGroup | Collecting multiple errors (form validation) |
contextlib.suppress() | Silently ignoring a specific exception |
| EAFP | The common case in Python |
| LBYL | When the check is simpler than the exception |
Common Mistakes
Catching Too Broadly
# BAD — hides bugs!
try:
result = complex_function()
except Exception:
result = default_value
# GOOD — catch only what you expect
try:
result = complex_function()
except (ValueError, KeyError) as e:
logger.warning(f"Expected error: {e}")
result = default_value
Silencing Errors
# BAD — you will never know something went wrong
try:
send_email(user)
except Exception:
pass
# GOOD — at least log the error
try:
send_email(user)
except SMTPError as e:
logger.error(f"Failed to send email: {e}")
Using Exceptions for Control Flow
# BAD — exceptions should be exceptional
try:
value = lst[index]
except IndexError:
value = "default"
# GOOD — use normal control flow when possible
value = lst[index] if index < len(lst) else "default"
The Exception Hierarchy
All Python exceptions inherit from BaseException. Here is the hierarchy:
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
├── StopIteration
├── ArithmeticError
│ └── ZeroDivisionError
├── LookupError
│ ├── IndexError
│ └── KeyError
├── OSError
│ ├── FileNotFoundError
│ └── PermissionError
├── ValueError
├── TypeError
└── RuntimeError
Important rules:
- Never catch
BaseException— it includesKeyboardInterrupt(Ctrl+C) andSystemExit - Catch
Exceptionas the broadest reasonable catch — but prefer specific exceptions - Your custom exceptions should inherit from
Exception, notBaseException
Source Code
You can find the code for this tutorial on GitHub:
kemalcodes/python-tutorial — tutorial-11-error-handling
Run the examples:
python src/py11_error_handling.py
Run the tests:
python -m pytest tests/test_py11.py -v
What’s Next?
In the next tutorial, we will learn about file I/O — reading, writing, and working with JSON, CSV, and temporary files.
Related Articles
- Python Tutorial #10: Dataclasses and Pydantic — modern data modeling
- Python Tutorial #9: OOP — classes, inheritance, magic methods
- Python Cheat Sheet — quick reference for Python syntax