Dead-letter handling
Process a thousand items and one of them is malformed. The naive loop crashes on item 400 — and the other 600 good items never get processed. That's a total failure caused by a partial problem, and it's a terrible way to run a pipeline.
The resilient pattern: wrap each item's processing in try/except. On success, collect the result. On failure, don't crash — route that item to a dead-letter list (often called a dead-letter queue, or DLQ) and carry on with the rest.
At the end you have two lists:
succeeded— everything that worked, with its result.dead_letter— everything that failed, with the error captured as a string so you can inspect it later.
This gives you partial-failure tolerance: one bad item costs you that one item, not the whole run. And because the failures are captured (not lost in a stack trace), you can fix the cause and retry just the dead-letter items later — re-running the failures without re-doing the successes.
This is exactly how SQS dead-letter queues, Celery's failure handling, and every mature job processor work.
Syntax
succeeded, dead_letter = [], []
for item in items:
try:
result = process(item)
succeeded.append({'item': item, 'result': result})
except Exception as e:
dead_letter.append({'item': item, 'error': str(e)})
# retry later: re-run only the dead_letter items
retry_items = [entry['item'] for entry in dead_letter]Worked examples
One item's outcome — success or captured error
def process_item(item, process_fn):
try:
result = process_fn(item)
return {'ok': True, 'item': item, 'result': result}
except Exception as e:
return {'ok': False, 'item': item, 'error': str(e)}
print(process_item(5, lambda x: x * 2))
print(process_item('x', lambda x: int(x)))
# Output:
# {'ok': True, 'item': 5, 'result': 10}
# {'ok': False, 'item': 'x', 'error': "invalid literal for int() with base 10: 'x'"}A bad item doesn't sink the whole run
def process_item(item, fn):
try:
return {'ok': True, 'item': item, 'result': fn(item)}
except Exception as e:
return {'ok': False, 'item': item, 'error': str(e)}
def run_pipeline(items, fn):
succeeded, dead_letter = [], []
for item in items:
outcome = process_item(item, fn)
(succeeded if outcome['ok'] else dead_letter).append(outcome)
return {'succeeded': succeeded, 'dead_letter': dead_letter, 'total': len(items)}
def positive_only(x):
if x < 0:
raise ValueError('negative')
return x * 10
out = run_pipeline([1, -1, 2, -2, 3], positive_only)
print(len(out['succeeded']), 'ok,', len(out['dead_letter']), 'dead, total', out['total'])
# Output:
# 3 ok, 2 dead, total 5Retry only the failures (after the cause is fixed)
def run_pipeline(items, fn):
succeeded, dead_letter = [], []
for item in items:
try:
succeeded.append({'ok': True, 'item': item, 'result': fn(item)})
except Exception as e:
dead_letter.append({'ok': False, 'item': item, 'error': str(e)})
return {'succeeded': succeeded, 'dead_letter': dead_letter, 'total': len(items)}
def retry_dead_letter(dead_letter, fn):
items = [entry['item'] for entry in dead_letter]
return run_pipeline(items, fn)
first = run_pipeline(['10', 'oops', '20'], int) # 'oops' fails
fixed = retry_dead_letter(first['dead_letter'], lambda s: 0) # fallback
print('first pass failures:', len(first['dead_letter']))
print('after retry:', len(fixed['succeeded']), 'recovered')
# Output:
# first pass failures: 1
# after retry: 1 recoveredCommon mistakes & gotchas
Catching the exception but losing the detail
except Exception: with a bare 'error': 'failed' throws away *why* it failed. Capture str(e) (or the repr) so the dead-letter entry tells you what went wrong — otherwise you can't diagnose or fix the cause.
Storing the live exception object instead of a string
Keeping the raw exception ('error': e) means it can't be cleanly serialised to JSON for a real queue, and the object may hold a whole traceback's worth of references. Convert to text with str(e) when you route to the dead-letter store.
Too broad an except hides real bugs
except Exception will also swallow a KeyError from your own typo, quietly dead-lettering items because of a bug in *your* code, not the data. Keep the processing function small, and when debugging, log the dead-letter errors — a sudden pile-up usually means a code fault, not bad input.
Why it matters
In any batch of real data, some items will be malformed, and a pipeline that dies on the first bad one is useless at scale. Dead-letter handling is what turns "the whole job failed" into "998 succeeded, 2 need a look" — partial failure is survivable, isolated, and retryable instead of catastrophic.