Skip to main content

justonce

Make side effects happen exactly once.

Your code charges a customer. The network hiccups. Your retry logic fires. The customer is charged twice.

def charge_customer(order):
    return payments.charge(order.customer, order.total)   # 💸 twice
from justonce import idempotent, operation_key

@idempotent(key=lambda order: operation_key("charge", order.id))
def charge_customer(order):
    return payments.charge(order.customer, order.total)   # ✅ once

That's it. The function now runs at most once per order — across retries, restarts, queue replays, and concurrent workers on different machines. And the outcome is recorded, so later you can ask "did anything get charged twice yesterday?" and get a real answer instead of a guess.


Why this exists

Every network call has three outcomes, not two: success, failure, and unknown. The unknown one — a timeout, a dropped connection, a process killed mid-write — is what creates duplicates, because the only safe response to "unknown" is to retry, and retrying something that already applied applies it twice.

"Exactly-once delivery" does not exist. What is achievable is at-least-once delivery with idempotent processing, which produces exactly-once effects. You cannot stop the duplicate arriving. This library makes it harmless.

The duplicates that matter are the irreversible ones: money moves, an email sends, inventory decrements, a webhook fires. Those aren't latency bugs — they're correctness bugs that reach customers, and they're usually discovered by finance rather than by monitoring.

Install

pip install justonce                 # SQLite included, no dependencies
pip install justonce[postgres]       # + Postgres store
pip install justonce[django]         # + Django store, uses your existing connection

Usage

import justonce
from justonce.stores import SqliteStore

justonce.configure(SqliteStore("effects.db"))

@justonce.idempotent(key=lambda order: justonce.operation_key("charge", order.id))
def charge_customer(order):
    return payments.charge(order.customer, order.total)

For a fleet, swap the store — nothing else changes:

from justonce.stores import PostgresStore
justonce.configure(PostgresStore("postgresql://localhost/app"))

Already on Django? Use the connection you have, on any backend Django supports:

from justonce.stores.django_store import DjangoStore
justonce.configure(DjangoStore())

One caveat worth reading before you ship it: a store on the default alias joins your ambient transaction.atomic() block. That is correct when the effect is a local write — claim and effect roll back together. It is wrong when the effect is an external call, because a rollback erases the claim while the charge stands, and the retry charges again. Point the store at a separate database alias in that case, and store.in_ambient_transaction() will tell you which mode you are actually in.

Choosing a store

Store Use when Not when
SqliteStore("path.db") single host — local dev, tests, a single-instance deployment you're running more than one machine or worker process
PostgresStore a fleet of workers sharing state, no existing Django app —
DjangoStore you already run Django and want to reuse its connection you haven't read the transaction caveat above

Durability matters more than it looks like it should, and the default is not durable. SqliteStore() defaults to path=":memory:", so a store constructed with no arguments forgets every key the moment the process exits. That's fine for a REPL session or a test; it's a silent bug in production, because it means the one scenario idempotency exists for — a retry that arrives after a deploy or a crash — is exactly when the store has no memory of what already happened.

If a key needs to survive process restarts, pass a real path (SqliteStore("justonce.db")) or use PostgresStore or DjangoStore.

Knowing whether this call did the work

@justonce.idempotent(key=..., return_result=True)
def charge_customer(order): ...

result = charge_customer(order)
if result.deduplicated:
    log.info("already charged", extra={"response": result.value})

Handling the in-flight duplicate

Another worker holds the claim and hasn't finished. Choose deliberately:

justonce.configure(store, on_in_flight=justonce.OnInFlight.RAISE)   # 409, default
justonce.configure(store, on_in_flight=justonce.OnInFlight.WAIT)    # block for the result

Never let a second caller proceed because the first "seems stuck" — a stalled attempt whose fate is unknown is exactly when duplicating is most expensive.

Reconciliation

Prevention is never complete. When a process dies between the effect and recording it, the key is left UNKNOWN rather than cleaned up — because "we don't know whether the customer was charged" is a fact worth keeping.

for record in engine.unresolved():
    outcome = payments.lookup(idempotency_key=record.key)   # ask the provider
    ...

Alert on the age of the oldest unresolved record, not the count. A stuck reconciliation is invisible in a count that stays flat.

Retention

justonce.configure(store, retention_seconds=30 * 24 * 3600)
engine.sweep()   # nightly

Retention is a correctness parameter, not a storage optimisation. It must outlive the longest chain that can re-deliver the same intent — including a dead-letter queue replayed a week later, and any provider dispute window. A 24-hour TTL behind a 7-day DLQ is a duplicate waiting to happen.

Choosing a key

The key must be stable across retries of the same intent and different across distinct intents. Nearly every idempotency bug is a key that breaks one of those:

uuid4()                        # ✗ new key per attempt — every retry is a new charge
f"{user_id}:{amount}"          # ✗ two legitimate $50 charges collapse into one
hash(cart.contents)            # ✗ key changes if the cart is edited mid-retry
f"{order.id}:{time.time()}"    # ✗ a timestamp is uuid4() wearing a hat

operation_key("charge", order.id)          # ✓ derived from an immutable identifier
request.headers["Idempotency-Key"]         # ✓ client-supplied, reused on retry

The key comes from the initiating event or the client — never from the layer doing the retrying.

What it guarantees

Situation Behaviour
Same key, same payload, called again Effect runs once; recorded response returned
Same key, different payload KeyReuseError — never serves the wrong response
Two workers, same key, same instant Exactly one runs the effect
Crash after effect, before recording Key left UNKNOWN; retries refuse until reconciled
Effect raised a transient error Claim released; a later attempt may retry
Effect raised a permanent error Key burned; no retry
Holder died mid-flight Claim reclaimable once its lease expires

Each row is a test in tests/test_exactly_once.py. If a guarantee isn't defended by a test that fails when you remove the logic, it isn't a guarantee.

How it works

claim ──won──> run effect ──> record outcome ──> return
  │
  └──lost──> terminal?  ──> return recorded response
             in-flight? ──> reject, or wait
             unknown?   ──> refuse; reconcile

The claim is a single atomic write guarded by a unique constraint — INSERT ... ON CONFLICT DO NOTHING. The database picks the winner. There is no SELECT before it, because a check followed by an act is a race:

# ✗ TOCTOU: both callers read "not seen", both charge
if not db.exists(key):
    charge_card(amount)
    db.insert(key)

The unique constraint is the mechanism. If a backend can't enforce uniqueness atomically, it can't be a store.

Compared to durable execution

Temporal, Restate, DBOS and friends solve a broader problem, and solve it well — but they ask you to restructure your application into workflows, which is why adoption stalls in existing codebases.

Durable execution justonce
Unit of protection The workflow One function call
Adoption cost Rewrite the app Add a decorator
Runtime required A server or cluster A table
"What did this actually do?" Via workflow history The core primitive

Use justonce when you want one dangerous call made safe this afternoon. Use a workflow engine when you need orchestration, timers, and long-running state.

FastAPI payment example

The copyable examples/fastapi_payment.py endpoint accepts an Idempotency-Key, uses a credential-free payment provider, and returns the recorded response on replay. Run it from the repository root:

uv run --extra examples uvicorn examples.fastapi_payment:app --reload

Open http://127.0.0.1:8000/docs to try the successful, replay, key-reuse (422), and concurrent in-flight (409) paths.

Contributing

The core is small on purpose. Most of the value is at the edges, and that's where help is most useful:

  • A store for your database — MySQL, DynamoDB, MongoDB, Redis, Spanner, D1. The contract is six methods, and justonce.conformance is an executable version of it. If your store passes the suite, it's correct by this project's definition.
  • A framework integration — Django, Flask, Celery, Dramatiq, Airflow, FastAPI middleware.
  • A provider adapter — map justonce keys onto Stripe, Adyen, Razorpay, PayPal native idempotency, so both sides agree on identity.

Adding a store is genuinely one file plus one conformance class. See CONTRIBUTING.md and the good first issues.

Licence

MIT

Metadata

Release files for justonce 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for justonce 0.2.0
File Size Uploaded
justonce-0.2.0.tar.gz 41.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for justonce 0.2.0
File Interpreter ABI Platform
justonce-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 75.9 kB

Release files / justonce-0.2.0.tar.gz

Download URL justonce-0.2.0.tar.gz
Size 41.3 kB
Tags Source
SHA-256 checksum
How to use checksums
9f9194331fcafc33875fc450b5cb42ecab60f323972d2fddd373f23c6ea63a1d
BLAKE2b-256 checksum
How to use checksums
7e1a64a255b780cd8ad2a5b5a1ce9ce6c1f80dc756a902b04c0f54e3a0c44c4a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 21, 2026.

Transparency log

Release files / justonce-0.2.0-py3-none-any.whl

Download URL justonce-0.2.0-py3-none-any.whl
Size 34.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
82faa94843a5291125347188d91a9d1941badfd4350c0ad0bc650e632a39d06c
BLAKE2b-256 checksum
How to use checksums
3bef44fc58de1f279341da3ac68f9ff05f9a6f336895e69f221ffcf09b3b2a30
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 21, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page