skip to content

`except Exception: continue` wraps each of 6,800 invoice rows in a nightly PDF renderer — what is wrong with that EAFP boundary?

level: seniorimportance: should knowfreq 40%

answer

  1. Which exceptions did that handler really catch?
  2. Bugs get relabelled as bad rows
  3. Systemic failure reported as a clean success
  4. No traceback survives the continue
  5. Narrow the try, name the exception, budget the failures

basics

~20 s

That is not EAFP, it is a swallowed exception. A handler that broad catches genuine bugs alongside bad rows, discards the traceback, and reports a batch where every row failed as a clean success. Narrow the try to the failing call, name the exceptions, log with the traceback, and fail the job when the failures look systemic.

solid answer

~50 s

EAFP means attempting an operation and catching **the exception that operation raises**; `except Exception` around a whole loop body catches everything the body can raise, which is a different thing. Three problems follow. First, it **misclassifies bugs as bad data**: a `TypeError` from a refactor or an `AttributeError` from a typo becomes "row skipped", so the defect ships. Second, it **hides systemic failure**: if the template file, the font, or the database connection is gone, all 6,800 rows fail identically and the job still exits zero with an empty output directory. Third, `continue` with no logging **destroys the traceback**, so nobody can diagnose it afterwards. The fix is to keep the `try` around the single fallible call, catch the specific exceptions that mean "this row is bad", log with `exc_info` so the traceback survives, count failures, and abort the run when the count or the ratio says the problem is not the data.

code

python · 21 lines
python
import logging

logger = logging.getLogger('render')


def render_batch(rows, render):
    failures = []
    for row in rows:
        try:
            pdf = render(row)
        except (KeyError, ValueError) as exc:
            logger.warning('invoice %s skipped', row.get('id'), exc_info=exc)
            failures.append(row.get('id'))
        else:
            yield pdf
    if failures and len(failures) == len(rows):
        raise RuntimeError('every invoice failed; aborting the batch')


rows = [{'id': 1, 'total': 10}, {'id': 2}]
print(list(render_batch(rows, lambda r: f'PDF {r["id"]}: {r["total"]}')))

go deeper

for a junior

Know that catching Exception around a whole block also catches your own bugs, and that an except body of just continue or pass leaves no trace of what went wrong or which item it happened on.

for a middle

Explain the mechanics: how wide the try block is, which exception types the clause actually matches, that Exception excludes KeyboardInterrupt and SystemExit, and how logging inside the handler preserves the traceback.

for a senior

Show the operating judgement — a per-item handler is a tolerance policy that needs a named exception set, a recorded failure list and a budget above which the job fails loudly rather than exiting zero with no output.

for a principal

Own the failure semantics of scheduled work across the fleet: what partial success means to downstream consumers, where retries and dead-letter handling live, and which alerts must fire when a run completes with an abnormal skip rate.

### Why this pattern appears `try: ... except Exception: continue` around a loop body is written with good intentions: a nightly batch should not die on row 12 of 6,800 because one invoice has a malformed total. The instinct — keep going, do the work you can — is right. The implementation converts a partially-failing job into a silently-wrong one. ### Problem 1: the except clause is far wider than the failure it is for `Exception` is the base of essentially every error your code can raise. The handler is written to mean "this row's data is bad", but it also catches: - `AttributeError` and `TypeError` from a genuine bug in the renderer — a renamed field, a `None` where an object was expected; - `NameError` from a typo on a rarely-taken branch; - `MemoryError`, a broken database connection, an expired credential, a missing template file — failures that are about the *environment*, not the row; - `ImportError` from a lazily-imported optional module. Every one is relabelled "skip this invoice". The class of bug this creates is the worst kind: the program keeps running and produces plausible output, so no alert fires and no test fails. (It does *not* catch `KeyboardInterrupt` or `SystemExit`, which derive from `BaseException` rather than `Exception` — a bare `except:` would catch even those, which is why bare `except:` is worse still.) ### Problem 2: the try block is far wider than the failing call EAFP's discipline is that the `try` contains only the operation whose exception you are handling. When the whole loop body is inside it, an exception raised by the *logging* call, the output-path construction, or the progress counter is caught by a handler written for the renderer. You can no longer tell from the code which line the handler is for, and neither can a reader. Tightening the block is usually a one-line change and it restores the meaning: the `try` documents which call is expected to fail. ### Problem 3: a systemic failure looks exactly like a data failure This is the one that turns a bug into an incident. If the renderer cannot reach the database, or the PDF template was removed in a deploy, then all 6,800 rows raise the same exception. The loop skips all 6,800, finishes, and exits zero. Every downstream signal is green — the scheduler saw a successful run, the duration looks normal — and the only evidence is an empty output directory nobody looks at until someone asks where the invoices are. Per-row tolerance must therefore come with a **budget**. Track the failure count and the failure ratio; if every row fails, or the first N in a row fail, or the ratio crosses a threshold, raise and let the job fail loudly. A batch that tolerates 3 bad rows in 6,800 is resilient; a batch that tolerates 6,800 bad rows in 6,800 is broken and pretending. ### Problem 4: `continue` throws away the evidence A handler whose entire body is `continue` (or `pass`) discards the exception object and its traceback the moment the block ends. Nothing records which invoice failed or why, so the post-mortem starts from zero. Inside the handler you still hold the live exception, so capture it: - `logging.exception(...)` inside an `except` block, or `logger.warning(..., exc_info=exc)`, writes the full traceback; - `traceback.format_exc()` gives you the same text as a string if you want it in a structured record; - appending the row identifier and the exception to a failures list lets you emit one summary at the end and re-drive just those rows. ### What the corrected shape looks like ```python failures = [] for row in rows: try: pdf = render(row) # only the fallible call except (KeyError, ValueError) as exc: # only the data errors logger.warning('invoice %s skipped', row['id'], exc_info=exc) failures.append(row['id']) else: write(pdf) # happy path, cannot be misattributed if len(failures) > BUDGET: raise RuntimeError(f'{len(failures)} of {len(rows)} invoices failed') ``` Four changes carry all the value: the `try` shrank to one call, the `except` names the exceptions that genuinely mean "bad row", the handler records rather than discards, and the loop has a failure budget above which the job fails. ### Choosing which exceptions are "bad row" This is a design decision, not a lookup. Work out what the renderer's own contract raises for invalid input — often a small set of `ValueError`, `KeyError`, or a project-specific error class — and catch exactly that. If the set is uncomfortably broad, that is a signal the renderer should raise its own narrow exception at its boundary rather than leaking whatever its internals hit. When the honest answer is "ignore this specific exception and do nothing", `contextlib.suppress(ThatError)` says so in one line and names the exception, which reads far better than an empty `except` body. ### The interview point The question separates people who have run batch jobs from people who have only written them. The senior answer is not "never catch broadly" — it is that a per-item handler is a *policy* about tolerable failures, and a policy needs a named exception set, a record of what it swallowed, and a limit past which it stops being a policy and becomes a bug.

  • Does `except Exception` stop a Ctrl-C from interrupting the batch?
    No. KeyboardInterrupt and SystemExit derive from BaseException, not Exception, so they pass straight through an `except Exception` handler and the job stops as expected. A bare `except:` catches BaseException as well, which is why a bare except inside a loop can make a program genuinely uninterruptible — every Ctrl-C is swallowed and the loop continues.
  • How do you keep the traceback when you have decided to continue?
    Capture it inside the handler while the exception is still live. logging.exception() in an except block, or logger.warning(msg, exc_info=exc), writes the full traceback to the log; traceback.format_exc() returns the same text as a string for a structured record. Also keep the row identifier alongside it, so the summary at the end tells you which items to re-drive rather than just how many failed.
  • When is contextlib.suppress the honest way to ignore an exception?
    When you genuinely want a specific named exception ignored and there is no handling to do — removing a file that may not exist, closing something already closed. suppress takes the exception types as arguments, so the code states exactly what is being ignored and a reader can check that decision. It is not a substitute for a handler that should log or record; it is a clearer spelling of a deliberate no-op.

It is a smoke alarm wired to a mute switch that the building's own maintenance crew presses every time it sounds: the night the whole floor is alight, the log still says quiet.

saying these in an interview costs you the question

  • Calls except Exception: continue an application of EAFP
  • Believes broad catching makes a batch more robust
  • Assumes a run that exits zero means every row was rendered
  • Logs only the exception message and drops the traceback
  • Sees no difference between a bad row and a missing template
  • Uses bare except: and claims it is equivalent to except Exception

context