Errors vs Logging: The Two-Channel Mental Model
Python programs communicate two fundamentally different kinds of information when something goes wrong, and this section covers both under one label - "errors and logging" - which can blur an important distinction.
Exceptions are a control-flow channel: raising one immediately stops normal execution and unwinds the call stack, searching for a handler, until something catches it or the program crashes.
Logging is an observability channel: a log call is a side effect that records what happened, and it never changes what code executes next, whether or not anyone is watching the logs.
Exceptions Basics and The logging Module each cover one channel hands-on; Raising & Chaining, Custom Exception Hierarchies, Exception Groups & except*, and Structured & Contextual Logging each go deep on one mechanism within a channel.
This page is the layer underneath all of them: why these two channels exist separately, and how they meet at the boundaries of a program.
Summary
- Exceptions control what code runs next by unwinding the call stack; logging records what happened without affecting what runs next - two channels, not one.
- Insight: Conflating them leads to the two most common failure modes in this area - exceptions used purely for logging noise, and logs used as a substitute for actually handling a failure.
- Key Concepts: control-flow channel, observability channel, exception hierarchy, exception chaining, exception group, structured logging.
- When to Use: Deciding whether a failure should stop execution (raise), be recorded for later analysis (log), or both; designing a custom exception hierarchy; choosing what belongs in a log record versus an exception message.
- Limitations/Trade-offs: Handling both channels correctly at every boundary is extra code - swallowing an exception silently or logging without ever raising both feel like shortcuts, and both quietly lose information a future debugging session will need.
- Related Topics: the
try/except/finallymodel, custom exception design, the standardloggingmodule, retry and backoff strategies.
Foundations
An exception's job is to interrupt normal execution the instant something makes continuing unsafe or meaningless - dividing by zero, a missing file, invalid input.
raise throws control immediately up the call stack, skipping the rest of the current function and every calling function in turn, until a matching except block catches it or the program terminates with a traceback.
That upward search is the mechanism: Python doesn't ask every function along the way whether it wants to handle the exception, it simply keeps unwinding until something explicitly says it will.
A logging call is architecturally nothing like that.
logger.error("payment failed", extra={"order_id": order_id}) runs, writes a record somewhere (console, file, a log aggregator), and returns - the next line of code executes exactly as if the call had been a no-op, because logging carries no control-flow meaning of its own.
The useful analogy: an exception is a fire alarm that stops the building's normal operations until someone responds, while a log line is a security camera recording that plays no active role in what happens next, only in what you can review afterward.
def charge(amount: float) -> None:
if amount < 0:
logger.error("invalid charge amount=%s", amount) # observability: just records
raise ValueError("amount must be non-negative") # control flow: actually stops executionThat single function shows both channels doing their separate jobs at once - the log line documents the failure for whoever reviews it later, and the raise is what actually prevents the invalid charge from proceeding.
Mechanics & Interactions
The exception hierarchy exists to let a caller catch a family of related failures without enumerating every specific subtype, which is a design decision entirely internal to the control-flow channel.
Catching OSError instead of FileNotFoundError, PermissionError, and IsADirectoryError separately works because all three inherit from OSError - the hierarchy is a taxonomy built for the unwinding search to match against, not a logging concern at all.
Exception chaining (raise NewError(...) from original) extends this same control-flow model across a translation boundary: when low-level code raises one exception and a higher layer wants to raise a different, more meaningful one, chaining preserves the original as __cause__ so nothing about why the new exception happened is lost, even though the exception's type changed.
Exception groups (ExceptionGroup, except*, 3.11+) extend the same unwinding model from "exactly one exception in flight" to "several unrelated exceptions in flight at once" - the natural result of, say, a task group where multiple concurrent operations each fail independently, and none of them should be silently dropped in favor of just the first one caught.
Logging's mechanics run on an entirely separate axis: a Logger doesn't unwind anything, it hands a record to handlers, which route it to formatters, which decide how the final text or JSON looks - a pipeline about where information goes, with no relationship to what code runs next.
Control-flow channel Observability channel
--------------------------------- ---------------------------------
raise -> unwind call stack -> except logger.error(...) -> handler -> formatter -> output
(stops normal execution) (side effect only, execution continues)
Where the two channels genuinely interact is at boundaries: a function often logs an error (for the record) and then re-raises or raises a translated exception (for control flow), because a caller several layers up needs to react to the failure, while an operator watching logs needs to see it happened at all - neither channel alone delivers both outcomes.
Advanced Considerations & Applications
At scale, this two-channel model explains why structured logging matters more than it first appears to.
A plain string log message ("payment failed for order 123") is fine for a human reading a terminal, but useless for a system that needs to query "show me every failed payment in the last hour grouped by error code" - structured logging attaches machine-queryable fields (order_id, error_code, correlation_id) to the same record, which is what makes the observability channel actually operable at production scale rather than just a scrollback buffer.
Retries and backoff sit squarely at the intersection of both channels: a transient failure should be logged (so operators see it happening), retried a bounded number of times (control flow deciding whether to try again), and finally either succeed silently or escalate to a real exception if retries are exhausted - conflating "log it" with "we handled it" is exactly how a service ships that silently retries forever without anyone noticing the underlying dependency is down.
Warnings (the warnings module) are a third, smaller channel worth distinguishing from both: a DeprecationWarning is neither a control-flow interruption nor a persistent operational log - it's a one-time-by-default notice aimed at developers during development and testing, filtered differently from both exceptions and application logs.
| Channel | Purpose | Stops execution? | Best Fit |
|---|---|---|---|
Exception (raise) | Signal that continuing is unsafe or meaningless | Yes, until caught | An operation cannot complete correctly and the caller must decide what happens next |
Logging (logging module) | Record what happened for later review or alerting | No | Anything an operator or future debugger needs visibility into, whether or not it's a failure |
Warning (warnings module) | Flag deprecated or discouraged usage to developers | No (by default) | API evolution, migration signals aimed at code authors, not production operators |
Common Misconceptions
- "Logging an error means the error was handled." A log call is a side effect with no bearing on control flow - if nothing also raises, retries, or otherwise reacts, the failure has been recorded but not actually dealt with.
- "Catching
Exceptionbroadly is safer because it prevents crashes." It prevents visible crashes, but it also silently absorbs bugs the exception hierarchy was designed to let specific handlers catch - narrower catches are usually the safer choice. - "
raise ... fromis just cosmetic traceback formatting." It sets__cause__, an actual attribute other tools and future debugging can inspect - it preserves real causal information, not just prettier output. - "Exception groups are for concurrency code only."
except*andExceptionGroupmodel "more than one unrelated failure happened in one operation," which shows up in batch validation and aggregation code even without any concurrency involved. - "More logging is always better." Excess or unstructured logging raises noise and cost without raising signal - the goal is queryable, actionable records, not maximum volume.
FAQs
What's the fundamental difference between an exception and a log record?
An exception is a control-flow signal - raising one interrupts normal execution and unwinds the call stack until something catches it. A log record is a side effect - it's written and execution continues exactly as it would have without it.
If I log an error, do I still need to raise or re-raise it?
Usually yes, if the failure means the current operation can't complete correctly. Logging documents that it happened; only raising (or an equivalent control-flow decision) actually stops the invalid state from propagating further.
Why does Python have an exception hierarchy instead of one generic exception type?
So callers can catch a family of related failures - like all OSError subclasses - without enumerating every specific type, while still allowing a handler further up to catch something narrower if it needs to react differently to a specific cause.
What does `raise ... from` actually preserve that a bare `raise NewError(...)` doesn't?
It sets the new exception's __cause__ attribute to the original exception, so the traceback and any code inspecting the exception chain can see both what ultimately surfaced and what originally caused it.
When would a single operation need to raise more than one exception at once?
When multiple independent sub-operations each fail on their own - concurrent tasks in a task group, or several items failing validation in a batch. ExceptionGroup and except* (3.11+) model that directly instead of forcing you to pick just one failure to report.
Is a warning the same thing as a logged error?
No. Warnings (via the warnings module) are aimed at developers, usually about deprecated or discouraged usage, and are filtered and displayed differently from application logs, which are aimed at operators watching a running system.
Why does structured logging matter more than readable text messages?
Because production systems need to query logs - "how many failures with this error code in the last hour" - and that requires consistent, machine-parseable fields, not just a human-readable sentence buried in a scrollback.
Is it ever correct to catch an exception and do nothing?
Rarely, and only when explicitly intentional - contextlib.suppress documents that choice deliberately. An empty except: pass with no comment is almost always a bug hiding a real failure from both channels.
How do retries fit into this two-channel model?
A transient failure gets logged (observability, so operators see it's happening), then retried a bounded number of times (control flow), and only escalates to a raised exception if retries are exhausted - each channel doing a distinct part of the job.
Should I log at the point an exception is raised, or where it's finally caught?
Often both, for different reasons: logging near the raise site captures the original context (what inputs, what state) while it's still available, and logging (or re-raising) at the top-level handler captures the final outcome for operators - losing either one loses part of the picture.
Does `BaseException` belong in this hierarchy the same way `Exception` does?
Not for application handling - BaseException also covers KeyboardInterrupt and SystemExit, which represent deliberate program termination rather than application-level failures, so application code should catch Exception or narrower, never BaseException.
Why can conflating exceptions and logs cause a service to fail silently?
If a piece of code logs a failure but never raises, retries, or otherwise reacts to it, the system's actual behavior is unaffected by the failure - it looks "handled" in the logs while quietly continuing in a broken state that nothing is actually correcting.
Related
- Exceptions Basics - the control-flow channel's core syntax
- The logging Module - the observability channel's core API
- Raising & Chaining - preserving causality across translation boundaries
- Custom Exception Hierarchies - designing the taxonomy callers catch against
- Exception Groups & except* - more than one failure in flight at once
- Structured & Contextual Logging - making the observability channel queryable
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.