Skip to main content

Error boundaries & logging habits

Level 3: Level 3: Patternsmedium17 minloggingexceptionserror-boundariesrobustness

Use logging instead of print and design where errors get caught.

Logging and where to handle errors

Why this matters

print is fine for a script you run once and watch. It falls apart in anything that runs unattended: a batch job, a web handler, a scheduled ETL. You cannot filter print by severity, cannot silence it in production without deleting lines, and cannot tell an error apart from a debug trace in a log file. logging fixes all three. The other half of robustness is deciding where a failure is handled. Get that wrong and one malformed row aborts a job that should have processed the other 99,999.

The logging model

A logger is a named channel. You grab one per module with logging.getLogger(__name__) and emit at a level: debug, info, warning, error, critical. Where those messages go (console, file, or both) and how verbose they are is configured once, at program start, not at each call site:

Check yourself
A fresh script does logger = logging.getLogger(__name__) and then logger.info('job started'). There is no other logging setup anywhere. What shows up on the console?
Table
The highlighted column is the whole of the vanishing-logs mystery: a fresh logger's effective level is WARNING, so debug and info are discarded silently until basicConfig(level=logging.INFO) lowers the bar. Nothing errors, the lines simply never appear.
LevelUse it forVisible by default?Who reads it
debugvalues while tracing a problemnoyou, while debugging
infonormal milestones: job started, 500 rows writtennoyou, reading yesterday's run
warningsomething odd but survivable: a retry, a fallbackyeswhoever is on call
errorthis operation failedyeswhoever is on call
criticalthe process cannot continueyeswhoever gets paged
The highlighted column is the whole of the vanishing-logs mystery: a fresh logger's effective level is WARNING, so debug and info are discarded silently until basicConfig(level=logging.INFO) lowers the bar. Nothing errors, the lines simply never appear.
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

logger.info("processing %d records", len(records))
logger.warning("skipping bad record: %r", raw)
Check yourself
Library code writes logger.debug('row %r failed', raw) rather than logger.debug(f'row {raw!r} failed'). Debug logging is switched off in production. What does the first form buy you?

Note the %d and %r with trailing args instead of an f-string. logging interpolates the message only if the record is actually emitted (more on that below).

Error boundaries: raise low, catch high

Do not wrap every line in try/except. Decide the boundary that can actually recover. The common shape: a low-level helper raises on bad input, and the loop that owns the batch catches and skips, so one bad record does not sink the rest.

def safe_total(raws):
    total = 0
    for raw in raws:
        try:
            total += int(raw)     # raises ValueError on "x"
        except ValueError:
            continue              # skip; real code would logger.warning(...)
    return total

print(safe_total(["1", "x", "3"]))   # 4

int("x") raises ValueError, the loop swallows just that one, and 1 + 3 gives 4.

Pitfalls

Check yourself
Your batch loop must skip rows that fail to parse, so you write: except Exception: continue. Weeks later someone introduces a typo in the loop body that references a name which does not exist. What does the job do?
  • Catching too broadly. except Exception: hides a typo'd name (NameError) alongside the errors you meant to skip; a bare except: is worse, also catching KeyboardInterrupt so you cannot even Ctrl-C out. Catch the specific type you expect (ValueError) and real bugs still surface.
  • int is pickier than you think. int("3.5") raises ValueError (it is not an integer literal), so safe_total(["3.5"]) returns 0, not 3. And int(None) raises TypeError, which except ValueError will not catch at all.
  • Silent logs. A fresh logger's effective level defaults to WARNING, so logger.info(...) prints nothing until you call basicConfig(level=logging.INFO). "My logs vanished" is almost always this.

Interview nuance: prefer logger.info("n=%d", n) over logger.info(f"n={n}"). logging checks isEnabledFor(level) first and only formats the message if the record will actually be emitted, so the %-style call skips string building when that level is off. The f-string builds the string eagerly on every call, including calls that log nothing. On a hot path with expensive %r values, that difference is measurable.

Check yourself

safe_total loops over raw values, does total += int(raw), and guards it with except ValueError: continue. Sort each incoming value by what the loop does with it.

'12'
' 7 '
'x'
'3.5'
an empty string
None
Worked example (Python)
def safe_total(raws):
    total = 0
    for raw in raws:
        try:
            total += int(raw)
        except ValueError:
            continue
    return total


print(safe_total(["1", "x", "3"]))   # 4

Apply

Your turn

The task this lesson builds to.

Warm-up (one file): implement safe_total(raws). Total the strings in raws that parse as integers, skipping any that don't.

safe_total(["1", "x", "3"]) is 4.

3 hints and 4 automated checks are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, so it survives past today.

Implement safe_total(raws) in processing/totals.py: use the read-only to_amount helper (which raises ValueError on bad input) to total the valid values, skipping the rest. Some tests are hidden.

3 hints and 2 automated checks are waiting in the workspace.