◐ ai-teacher

🏗 Scale & System Design · Chapter 2

Idempotency — surviving the same event twice

Distributed systems retry. Retrying is not the bug — charging twice is.

Study this lesson free in the app →

The basics

Idempotency means an operation gives the same result whether it runs once or many times. Pressing a lift button five times still calls one lift.

The problem it solves: distributed systems retry. A Kafka consumer writes a charge, crashes before committing its offset, restarts and reads the same event again. A network timeout makes a client resend a request that actually succeeded. You cannot stop these retries — they are how systems avoid losing data. What you can do is make the retry change nothing, so the customer is charged ₹280 once, not twice.

The mental model: a hotel reception desk that asks "which booking?" before issuing a key card. With a booking number it can tell a retry from a new request. That number is the idempotency key, and it must come from whoever created the request, not be invented on arrival.

Words you must know 5

At-least-once delivery
nothing is lost, but duplicates happen; the industry default
Idempotency key
a producer-generated unique id (event_id) that identifies the same request across retries
Upsert / ON CONFLICT
a write that inserts or does nothing/updates when the key already exists
Naturally idempotent write
SET status = 'completed' (safe to repeat), unlike balance = balance + 100
Effectively-once
at-least-once delivery plus idempotent processing

Explained

A payment consumer processes an event, writes the charge, then crashes before committing the offset.

On restart it reads the same event again. Two charges, one ride, one very angry customer and one very expensive support ticket.

You cannot prevent the retry. You design so the retry changes nothing.

The hotel key card

You ask reception for a key card. They make one.

You ask again — maybe the first request never reached them, maybe it did and the reply was lost. You genuinely do not know.

Non-idempotent reception hands you a second card and bills you twice.

Idempotent reception asks "which booking?", sees a card was already issued for booking 9001, and hands you the same one. Same result, no matter how many times you ask.

The booking number is the idempotency key. Without it, reception cannot tell a retry from a new request.

The three delivery semantics

So the real-world pattern is:

at-least-once delivery + idempotent processing = effectively-once

That phrase is the answer to almost every reliability question in an interview. Learn to say it and then explain the two halves.

Four ways to make a write idempotent

-- 1. Unique constraint + upsert (the workhorse)
CREATE UNIQUE INDEX ON payments (event_id);

INSERT INTO payments (event_id, ride_id, amount)
VALUES ('evt_901', 'r_9001', 280.50)
ON CONFLICT (event_id) DO NOTHING;
# 2. Dedup by key before the side effect
def handle(event, seen):                 # seen = Redis / state store
    if seen.exists(event["event_id"]):
        return                            # already done, silently succeed
    charge(event, idempotency_key=event["event_id"])
    seen.set(event["event_id"], ttl=7 * 86400)
-- 3. Idempotent batch: delete-then-insert the whole partition
DELETE FROM rides_daily WHERE ride_date = '2026-07-31';
INSERT INTO rides_daily SELECT ... WHERE ride_date = '2026-07-31';
-- re-running the job produces the same table, never doubled rows
4. Pass an idempotency key to the external API.
   Stripe, Razorpay, and every serious payment provider accept one
   for exactly this reason. Reuse the event_id.

Where the state lives is the hard part

Remembering "have I seen this id?" across billions of events is the real engineering.

That last bullet is the highest-leverage idea in the topic. Prefer writes that are naturally idempotent over machinery that detects duplicates.

At-least-once delivery + idempotent processing = effectively once.

SET is idempotent. ADD is not.

The idempotency key is the question "which one did you mean?"

Gotchas

The RideFlow answer

"Kafka gives me at-least-once, so duplicates are a certainty rather than a risk. Every event carries a producer-generated eventid. The payment consumer writes into a table with a unique index on event_id and uses INSERT … ON CONFLICT DO NOTHING, and passes the same event_id as the idempotency key to the payment provider so its side is protected too. That makes reprocessing free — I can replay from any offset after a bug fix without reconciling anything by hand."_

Expect the follow-up: "and if the same ride is legitimately charged twice?" — that is why the key is event_id, not ride_id.

Words to know

IdempotentAn operation that produces the same result whether applied once or many times.
Idempotency keyA caller-supplied unique id letting the receiver recognise a retry.
At-least-onceDelivery guarantee where messages are never lost but may repeat.
Effectively-onceAt-least-once delivery made safe by idempotent processing.
Upsert / MERGEInsert or update in one atomic statement, keyed on a unique column.
Dead letter queue (DLQ)Where events that repeatedly fail are parked for inspection, so one bad record cannot stall a partition.

In two minutes

Test yourself

1. Which write is naturally idempotent?

  1. UPDATE wallets SET balance = balance + 100 WHERE id = 7
  2. UPDATE rides SET status = 'completed' WHERE ride_id = 'r1'
  3. INSERT INTO payments VALUES (...)
  4. DELETE FROM rides WHERE created_at < now() - interval '1 day'
Show the answer

UPDATE rides SET status = 'completed' WHERE ride_id = 'r1' — Setting an absolute value produces the same state however many times it runs. Incrementing accumulates; plain inserts duplicate.

2. Kafka delivers a payment event twice. The correct fix is to…

  1. Delete the old row and insert the new one
  2. Check the event_id and skip if already processed
  3. Switch the consumer to at-most-once
  4. Increase the number of partitions
Show the answer

Check the event_id and skip if already processed — Deduplicate on a producer-generated event_id. Deleting and re-inserting risks a window with no row at all, and at-most-once trades duplicates for data loss.

3. Who must generate the event_id?

  1. The consumer, on receipt
  2. The producer, when the event happens
  3. Kafka, automatically
  4. The database, as a sequence
Show the answer

The producer, when the event happens — A consumer-generated id is fresh on every retry, so dedup becomes impossible. The id must travel with the event from its origin.

4. Your dedup cache keeps ids for 1 hour. You replay a 6-hour-old DLQ batch. What happens?

  1. Duplicates are still caught
  2. Duplicates slip through — the window is shorter than the retry horizon
  3. The cache auto-extends
  4. Kafka blocks the replay
Show the answer

Duplicates slip through — the window is shorter than the retry horizon — A bounded dedup window must be at least as long as your worst-case retry/replay horizon, or back it with a durable unique constraint.

5. A consumer commits its Kafka offset first, then processes the event. It crashes between the two. What delivery semantics does that give?

  1. At-most-once — the event is never processed again, so it can be lost
  2. At-least-once
  3. Exactly-once
  4. Effectively-once
Show the answer

At-most-once — the event is never processed again, so it can be lost — The offset already says 'done', so on restart the consumer skips the event that was never processed. Fine for a page-view counter, unacceptable for payments. Process-then-commit flips it to at-least-once, which is why idempotency is needed.

6. A nightly job loads 31 July into rides_daily with a plain INSERT … SELECT. It fails half-way, and the scheduler retries the whole job. What does the table hold for 31 July?

  1. Exactly one copy — the database dedupes
  2. Only the second run's rows
  3. The partial rows from the first run plus a full second copy
  4. Nothing — the failure rolled back both runs
Show the answer

The partial rows from the first run plus a full second copy — Append is not idempotent. Unless the first attempt ran in one transaction that rolled back, its partial rows stay, and the retry adds a full set on top. Overwrite the partition — delete and insert in one transaction, MERGE, or INSERT OVERWRITE — so any number of runs produces the same table.

There are 8 questions, 3 puzzles and 5 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →