๐ฅ quarantine
When one bad item crashes your loop of 10,000 โ don't crash. Set it aside, keep going, fix it later.
pip install quarantine-py
from quarantine import quarantine
@quarantine
def process(item): ... # your normal code, unchanged
for item in items:
process(item)
That's it. Bad items no longer kill your job. They get saved to a .quarantine/ folder โ with their full error and the exact input that caused it โ and your loop keeps running.
At the end:
โ 9,996 processed ยท โ 4 quarantined โ .quarantine/ (run `quarantine retry` after fixing)
The problem (explained like you're new to this)
Imagine you're processing 10,000 records โ rows from a CSV, URLs to scrape, images to resize. You write a loop:
for item in items:
process(item)
You run it. It works... until item #5,247, which is malformed in some way you didn't expect. Your script crashes. You lost 5,246 items of finished work and 3 hours.
So you fix the bug for that one weird item and rerun from the start. Three hours later it crashes again โ at item #7,913, for a different reason.
This cycle is one of the most common, most painful experiences in programming. quarantine ends it.
"Can't I just use try/except?"
Yes โ try/except is exactly what quarantine uses under the hood. But here's the try/except version once you make it actually safe:
failed = []
for item in items:
try:
process(item)
except Exception as e:
failed.append(item) # โ lost forever if the script dies later
print(f"failed: {e}") # โ traceback gone โ good luck debugging tomorrow
# โ how do I re-run JUST these failures after I fix the bug?
# โ what if 500 fail in a row because the API is down โ keep going?!
# โ how do I save a weird object (DataFrame row? bytes?) to look at later?
Every โ is a real problem you'd have to solve yourself, in every script, forever. quarantine solves them once:
try/except by hand |
@quarantine |
|
|---|---|---|
| Loop survives bad items | โ | โ |
| Failures survive a crash/restart | โ in RAM, gone | โ saved to disk instantly |
| Full traceback kept for later | โ usually just printed | โ stored with the item |
| The exact bad input saved | โ you'd have to serialize it | โ automatic |
| Re-run only the failures | โ build it yourself | โ
quarantine retry |
| Debug with the real bad input | โ archaeology in logs | โ
quarantine debug 2 |
| Detects "everything is failing, stop" | โ | โ halts on failure streaks |
| Skips already-known-bad items on rerun | โ | โ |
quarantine is not a replacement for try/except. It's the 200 lines of bookkeeping you'd have to write around it โ done correctly, once.
The idea in one picture
Hospitals don't shut down when one patient has an infection. They quarantine the patient, treat everyone else, and come back with the right medicine.
items โโโถ process() โโโถ โ done
โ
โ raises an exception
โผ
.quarantine/ โโโ the "sick bay" folder
โโโ item + its data
โโโ the full error traceback
โโโ when/why it failed
Your job finishes. The sick items wait for you, with their full medical charts.
Installation
pip install quarantine-py
That is the whole install story. Some alternatives, if you prefer:
uv add quarantine-py # uv projects
uv pip install quarantine-py # uv, without a project
python -m pip install --user quarantine-py
pip install git+https://github.com/halcyon-past/quarantine # unreleased main
Requirements
| Python | 3.10 or newer (CPython; tested on 3.10 - 3.13) |
| Runtime dependencies | none - it is standard library only |
| Operating systems | Linux, macOS, Windows (tested on all three in CI) |
Installing also puts a quarantine command on your PATH. Check both halves:
$ quarantine --version
quarantine 0.1.0
$ python -c "import quarantine; print(quarantine.__version__)"
0.1.0
If the command is not found (a common --user install wrinkle), the module
form always works and takes the same arguments:
python -m quarantine list
Nothing needs configuring. The first time a call fails, .quarantine/ appears
next to wherever you started Python. Add it to your .gitignore -
quarantined inputs are real data, and real data does not belong in git.
.quarantine/
Usage
1. Basic โ decorate and forget
from quarantine import quarantine
@quarantine
def process(row):
price = float(row["price"]) # crashes on "N/A"? quarantined.
save_to_db(row["id"], price)
for row in rows:
process(row)
2. See what got quarantined
$ quarantine list
# when function error input preview
1 09:14:02 process ValueError: could not convert {'id': 8812, 'price': 'N/A', ...}
2 09:31:44 process KeyError: 'price' {'id': 9107, ...}
3. Fix your code, then retry only the failures
$ quarantine retry
โ 3 recovered ยท โ 1 still failing (kept in quarantine)
No rerunning the 9,996 items that already worked.
(Note: If your function lives in a standalone script rather than an installed package, use -i to tell quarantine where to import it from: quarantine retry -i my_script.py)
4. Debug with the actual bad input
$ quarantine debug 2
# opens a Python debugger with `item` set to the exact input that failed
(You can use -i here too: quarantine debug 2 -i my_script.py)
The single biggest time-saver: you never have to reproduce the bug. The bug's exact input is sitting on disk.
5. Safety valve โ when it's not the data's fault
If 50 items fail in a row, that's not bad data โ that's your database being down. Quarantining 10,000 items would be silly. quarantine halts instead:
โ 50 consecutive failures โ this looks systemic, not bad data. Halting.
Last error: ConnectionError: db.internal:5432 refused
Tune it: @quarantine(halt_after=100).
6. Options (all optional)
@quarantine(
dir=".quarantine", # where the sick bay lives
only=(ValueError, KeyError), # only quarantine these; others still crash
halt_after=50, # consecutive-failure circuit breaker
max_items=10_000, # cap disk usage
redact=["api_key", "password"], # scrub these fields before saving inputs
on_quarantine=my_alert_fn, # e.g., send a Slack ping
)
def process(item): ...
7. Works on loops too, without a decorator
from quarantine import shield
for item in shield(items, using=process):
...
What's in the .quarantine/ folder?
Plain files. No database, no magic โ you can inspect everything yourself:
.quarantine/
โโโ 0001/
โ โโโ input.pkl # the exact item (pickle, JSON fallback for simple data)
โ โโโ input.txt # human-readable repr, so you can just *look* at it
โ โโโ traceback.txt # full error, exactly as it would have printed
โ โโโ meta.json # function name, timestamp, attempt count, python/pkg versions
โโโ index.json
Design rules:
- Atomic writes โ a crash mid-save never corrupts the folder.
- Redaction before disk โ fields you mark as secret never touch the filesystem.
- Dedup on rerun โ an item already in quarantine is skipped (no log spam), unless you
quarantine retryit. - Serialization fallbacks โ pickle โ JSON โ repr. Something readable is always saved, even for exotic objects.
Command-line reference
Every command takes -d/--dir PATH (default: $QUARANTINE_DIR, else
./.quarantine), and list, show, retry and stats all take --json so
you can pipe them somewhere useful.
| Command | What it does |
|---|---|
quarantine list |
Table of everything quarantined. ls works too. -f/--function NAME, -n/--limit N. |
quarantine show ID [ID...] |
One record in full: metadata, the input, the whole traceback. |
quarantine retry [ID...] |
Re-run records; delete the ones that now succeed. -f/--function NAME, --dry-run, -i/--import FILE.py (for functions that live in a script). |
quarantine debug ID |
Re-run one record and drop you into pdb on the frame that raised. -p/--print to just dump it, --no-post-mortem to skip re-running and get the input in scope, -i/--import FILE.py as above. |
quarantine clear [ID...] |
Delete records. With no ids it clears everything and asks first; -y/--yes skips the prompt. rm works too. |
quarantine stats |
Counts by function and by error type, plus how much disk the folder is using. |
quarantine reindex |
Rebuild index.json from the record folders and sweep up leftover temp files from a hard crash. |
Exit codes, for scripts and CI:
| Code | Meaning |
|---|---|
0 |
Everything you asked for succeeded. |
1 |
The command ran, but something is still wrong - a retry failed again, or a record could not be replayed. |
2 |
Bad usage, or the folder could not be read. |
# fail a nightly job if anything is still sitting in quarantine
quarantine retry || echo "still broken - look at: $(quarantine list -n 3)"
Python API reference
from quarantine import quarantine, shield, Quarantine, QUARANTINED, records, retry
Decorating and looping
@quarantine / @quarantine(...) |
Wrap one function. Options are listed under Options. Works on async def. |
shield(items, using=fn, **options) |
Iterator yielding only the results that worked. |
ashield(items, using=fn, **options) |
Same, for async def work and/or async iterables. |
Return values. A quarantined call returns the QUARANTINED sentinel; an
input recognised as already-bad returns SKIPPED. Both are falsy, so
if process(item): does the sensible thing. Use is_quarantined(result) /
is_skipped(result) when you want to be explicit.
The explicit object, when you would rather pass something around than rely on a decorator:
from quarantine import Quarantine
q = Quarantine("build/bad-rows", halt_after=10, redact=["api_key"])
safe = q.wrap(process) # same as the decorator
q.call(process, item) # one-off call, same protection
await q.acall(fetch, url) # async one-off
q.records() # list[Record], oldest first
q.retry() # -> RetryResult(recovered, still_failing, unretryable)
await q.aretry() # for records from async functions
q.clear() # empty the folder
q.stats # Stats(processed, quarantined, skipped, recovered)
q.summary_line() # the one-line report, or None
len(q), list(q) # how many records; iterate them
Module-level shortcuts operate on the default folder (or dir=):
records(), retry(), aretry(), clear(), summary().
A Record is what you get back from records():
record = records()[0]
record.id # 1
record.function # "process" (qualified name in .qualified_name)
record.error_type # "ValueError"
record.summary # "ValueError: could not convert string to float: 'N/A'"
record.attempts # 2, after one retry
record.redacted # ["api_key"] - what was scrubbed
record.path # Path(".quarantine/0001")
record.traceback_text() # the stored traceback
record.load_call() # Call(args=({...},), kwargs={}) - the original input
record.load_call().item # the item itself
Exceptions (all subclass QuarantineError, and none of them are ever
quarantined themselves):
SystemicFailure |
The halt_after circuit breaker tripped. .count, .last_error. |
QuarantineFull |
max_items reached. Nothing is dropped silently - this is raised instead, chained from the original error. |
StorageError |
The folder could not be read or written. |
ResolutionError |
A retry could not import the function a record came from. |
Environment
QUARANTINE_DIR |
Default folder for both the library and the CLI. |
Documentation
| docs/installation.md | Installing, verifying, upgrading, uninstalling. |
| docs/usage.md | The full guide: options, async, threads, retry loops, alerting, recipes. |
| docs/cli.md | Every command, flag and exit code, with output samples. |
| docs/api.md | Complete Python API reference. |
| docs/on-disk-format.md | What is in .quarantine/, and the guarantees about it. |
| docs/troubleshooting.md | "It skipped my item", "retry says it cannot import", and friends. |
| docs/faq.md | Longer answers to the questions below. |
| CHANGELOG.md | What changed, and when. |
| CONTRIBUTING.md | Setup, the checks, and the ground rules for changes. |
When NOT to use quarantine
Honesty section:
- You want the crash. In a bank transfer pipeline, stopping on the first error might be correct. Silently continuing is a choice โ make it deliberately.
- You're already on Celery/Kafka/Airflow. Those have dead-letter queues; use them. quarantine is for the 95% of scripts that will never justify that machinery.
- Failures are expected and normal (e.g., "404 means skip"). Handle those with a normal
if/exceptโ quarantine is for unexpected failures you'll want to investigate.
FAQ
Is this just a dead-letter queue? Yes โ that's exactly the pattern, ported from message-queue infrastructure to a plain Python for-loop. No broker, no server, one decorator.
Async?
@quarantine works on async def too.
Threads/processes? Yes โ writes are atomic and the folder is append-only per item.
Created by Aritro Saha
Made for everyone whose overnight job died at item 5,247.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file quarantine_py-0.1.3.tar.gz.
File metadata
- Download URL: quarantine_py-0.1.3.tar.gz
- Upload date:
- Size: 79.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f7d01926cbad1fe30e29da3909ddf1756d0efa2547cf7db7002d80f324683f82
|
|
| MD5 |
5ab76030f9483ea16e04604b3e0800d2
|
|
| BLAKE2b-256 |
f5b11b341d262da64e518e16c7a8121b34a63a6581d74864fa7ee31319699b3c
|
Provenance
The following attestation bundles were made for quarantine_py-0.1.3.tar.gz:
Publisher:
publish.yml on halcyon-past/quarantine
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
quarantine_py-0.1.3.tar.gz -
Subject digest:
f7d01926cbad1fe30e29da3909ddf1756d0efa2547cf7db7002d80f324683f82 - Sigstore transparency entry: 2581571243
- Sigstore integration time:
-
Permalink:
halcyon-past/quarantine@142983cdade675356f87931d5971aefd0fd054cc -
Branch / Tag:
refs/tags/v0.1.3 - Owner: https://github.com/halcyon-past
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@142983cdade675356f87931d5971aefd0fd054cc -
Trigger Event:
release
-
Statement type:
File details
Details for the file quarantine_py-0.1.3-py3-none-any.whl.
File metadata
- Download URL: quarantine_py-0.1.3-py3-none-any.whl
- Upload date:
- Size: 44.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0ac4f95c1def98e74ea93baa7f7aefbb62c1117e7c3780882b1cf62e5813d5e3
|
|
| MD5 |
1051caee46fdbdd0431edaac8cbfe7c4
|
|
| BLAKE2b-256 |
805c4e944f87675af40d2e67e76bd5cfa3d501effcd025b9bae9d5e086a43cb3
|
Provenance
The following attestation bundles were made for quarantine_py-0.1.3-py3-none-any.whl:
Publisher:
publish.yml on halcyon-past/quarantine
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
quarantine_py-0.1.3-py3-none-any.whl -
Subject digest:
0ac4f95c1def98e74ea93baa7f7aefbb62c1117e7c3780882b1cf62e5813d5e3 - Sigstore transparency entry: 2581571254
- Sigstore integration time:
-
Permalink:
halcyon-past/quarantine@142983cdade675356f87931d5971aefd0fd054cc -
Branch / Tag:
refs/tags/v0.1.3 - Owner: https://github.com/halcyon-past
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@142983cdade675356f87931d5971aefd0fd054cc -
Trigger Event:
release
-
Statement type: