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 protecting
  • except — what to do when a specific type of exception occurs
  • else — 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.