Chapter 8 Key Takeaways: Error Handling
The Central Idea
Real-world business data is messy. Files go missing. Columns get renamed. Fields contain "N/A" when a number was expected. Networks time out. Users type text into numeric fields. None of this is unusual — it is the normal operating environment of any business application.
Robust code does not assume data will be perfect. It anticipates failure, handles it gracefully, and tells someone what went wrong.
Concept Summary
1. Exceptions Are Python's Error Signal System
When Python cannot execute an operation, it raises an exception — a signal that something went wrong. If your code does not catch that signal, Python prints a traceback and stops. Understanding the exception hierarchy (how KeyError, ValueError, IndexError, etc. relate to each other) lets you write handlers that are precisely targeted.
The hierarchy that matters most:
Exception
├── ArithmeticError → ZeroDivisionError
├── LookupError → KeyError, IndexError
├── ValueError (right type, wrong value)
├── TypeError (wrong type entirely)
└── OSError → FileNotFoundError, PermissionError
2. try/except/else/finally: Four Clauses, One Structure
try:
# Attempt the risky operation
result = risky_operation()
except SpecificError as e:
# Runs ONLY if SpecificError was raised
handle_failure(e)
else:
# Runs ONLY if NO exception was raised
use_result(result)
finally:
# ALWAYS runs — use for cleanup
release_resources()
try— the operation you are protectingexcept— what to do when a specific type of exception occurselse— what to do on success (only runs if no exception)finally— cleanup that must always happen (file close, connection release)
3. Specific Exceptions Beat Generic Ones Every Time
Never use:
except: # Catches KeyboardInterrupt, SystemExit, everything
except Exception: pass # Silently swallows all errors
Always prefer:
except FileNotFoundError: # This specific problem
except ValueError: # This specific problem
except (ValueError, KeyError): # These two specific problems, same response
Catching the specific type lets you give a specific message — and a specific message tells the user what to do, not just that something broke.
4. raise: You Can Signal Errors Too
Use raise to signal problems your code detects that Python's built-ins would miss:
if revenue < 0:
raise ValueError(f"Revenue cannot be negative: {revenue}")
if region not in VALID_REGIONS:
raise ValueError(f"Unknown region '{region}'. Valid: {VALID_REGIONS}")
Use bare raise (no arguments) inside an except block to re-raise after logging:
except FileNotFoundError:
logger.critical("Config file missing — cannot start")
raise # Re-raises the same exception with its original traceback
5. Custom Exceptions Give You a Business-Specific Error Vocabulary
Inherit from Exception to create exceptions that name business concepts:
class BusinessError(Exception):
pass
class ValidationError(BusinessError):
def __init__(self, field_name, value, reason):
super().__init__(f"'{field_name}' validation failed: {reason} (got {value!r})")
self.field_name = field_name
self.value = value
self.reason = reason
Benefits:
- Exception names document what went wrong at the business level
- Stored attributes let calling code respond programmatically (not just print a string)
- You can catch broad (BusinessError) or narrow (ValidationError) as needed
6. Replace print() with logging
In production scripts, use the logging module instead of print():
print() |
logging equivalent |
Level |
|---|---|---|
print("Starting...") |
logger.info("Starting...") |
INFO |
print("Skipping bad row...") |
logger.warning("Skipping...") |
WARNING |
print("ERROR: file missing") |
logger.error("File missing") |
ERROR |
| Developer diagnostic | logger.debug("Value is: X") |
DEBUG |
| Cannot continue | logger.critical("DB down") |
CRITICAL |
Configuration pattern (use once, at startup):
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s | %(levelname)-8s | %(message)s",
handlers=[logging.StreamHandler(), logging.FileHandler("app.log")],
)
logger = logging.getLogger(__name__)
Log exceptions with tracebacks:
try:
risky_operation()
except Exception:
logger.exception("Unexpected failure in risky_operation") # includes traceback
7. Validate Before You Process
Checking your data before the main processing loop prevents deep-in-loop failures that are harder to recover from:
# Check file structure first
if not has_required_columns(filepath, REQUIRED_COLUMNS):
logger.error("File structure invalid — aborting before processing")
return
# Then process with confidence
for row in rows:
process_row(row)
Reusable validation functions with clear names and error messages make your code readable and your errors actionable:
def validate_amount(raw: str) -> float:
"""Returns parsed amount or raises ValueError with a clear message."""
...
8. Graceful Degradation: Partial Success Beats Total Failure
Design your processing loops so that an error in one item does not stop all subsequent items:
results = []
for item in items:
try:
results.append(process(item))
except ValidationError as e:
logger.warning(f"Skipping {item['id']}: {e}")
continue # Move to the next item
The pattern: error boundary at the item level, log and continue, collect statistics, report a summary.
A report that covers 847 out of 850 rows and clearly explains why 3 were skipped is vastly more useful than no report at all.
9. Error Messages Should Be Actionable
The goal of every error message is to help someone fix the problem without needing a developer to interpret it.
Useless: "Error processing invoice"
Useful: "Invoice INV-2024-0847: amount field contains 'N/A' — likely a pending reconciliation entry. Use 0.00 if no charge, or include the amount and rerun."
Every good error message answers three questions: 1. What specifically failed? (field name, value, row identifier) 2. Why did it fail? (what was expected vs. what was received) 3. What should the reader do? (action to resolve)
10. The ProcessingReport Pattern
For batch jobs, accumulate statistics throughout processing and print a summary at the end:
@dataclass
class ProcessingReport:
rows_processed: int = 0
rows_skipped: int = 0
errors: list[str] = field(default_factory=list)
def record_skip(self, row_id, reason):
self.rows_skipped += 1
self.errors.append(f"{row_id}: {reason}")
A summary like "847 rows processed, 3 skipped — see log for details" is far more useful than a silent completion or an unexplained crash.
Quick Reference: When to Use Each Tool
| Situation | Tool |
|---|---|
| Protecting code from a known exception type | try/except SpecificError |
| Running code only on success | try/except/else |
| Cleanup that must always happen | try/finally |
| Signaling a business logic violation | raise ValueError(...) |
| Naming a domain-specific error | Custom exception class |
| Logging normal progress | logger.info() |
| Logging a skipped item | logger.warning() |
| Logging a partial failure | logger.error() |
| Logging with full traceback | logger.exception() |
| Making all output go to a file | logging.FileHandler |
What Comes Next
Chapter 9 — Working with External APIs — builds directly on these patterns. Network requests can fail in all the ways covered here (connection errors, timeout errors, encoding errors) plus new ones: HTTP error codes, malformed JSON responses, authentication failures, and rate limiting. Everything you learned in Chapter 8 applies, with new exception types to learn.
One-Sentence Version
Write code that expects bad data, catches specific exceptions, logs exactly what went wrong and why, and continues processing the rest — because partial output with clear error notes is always better than a crash.