0

Idempotency — why GET and PUT are idempotent but POST is not

Intermediate5 min read·eng-13-002
interview

Concept

Idempotency — a property of operations where applying the same operation multiple times produces the SAME result as applying it once. The key is the END STATE, not what happens during each call.

Etymology: From mathematics. f(f(x)) = f(x) — applying the function repeatedly doesn't change the result beyond the first application.

HTTP method idempotency:

  • GET: Idempotent + Safe. Fetching /users/42 multiple times always returns the same user (assuming no changes). No side effects.
  • PUT: Idempotent. PUT /users/42 {"name": "Alice"} — set the resource to this state. Calling it 5 times leaves the user as {"name": "Alice"} — same end state.
  • DELETE: Idempotent. DELETE /users/42 — first call deletes. Second call returns 404 (or 204). The end state is the same: user 42 does not exist.
  • HEAD: Idempotent + Safe. Same as GET but returns headers only.
  • OPTIONS: Idempotent + Safe.
  • POST: NOT idempotent. POST /orders creates a new order each time. Calling it 5 times creates 5 orders. The end state differs.
  • PATCH: NOT necessarily idempotent. PATCH /users/42 {"balance": balance+10} — called multiple times, the balance increases each time.

Why idempotency matters for APIs:

  • Retry safety: Network failures happen. If GET/PUT/DELETE fail, clients can safely retry. If POST fails, retrying may create duplicates.
  • At-least-once delivery: Message queues often guarantee "at least once" — your consumer must be idempotent to handle duplicate messages.
  • Idempotency keys: For non-idempotent operations (payments, orders), clients send an Idempotency-Key header with a unique UUID. The server stores the result and returns the same result for duplicate keys.

Safe ≠ Idempotent: A safe method has no side effects. An idempotent method can have side effects (DELETE changes state), but the end state stabilizes after the first call.

Code Example

php
<?php
// Idempotent PUT — calling multiple times gives same result
Route::put('/users/{id}', function (int $id, UpdateUserRequest $request) {
    // REPLACE the entire resource — idempotent
    $user = User::findOrFail($id);
    $user->update($request->validated()); // same state regardless of how many times called
    return response()->json($user);
});

// Idempotent DELETE
Route::delete('/users/{id}', function (int $id) {
    $user = User::find($id);
    if (!$user) return response()->noContent(); // 204 — already gone, still idempotent
    $user->delete();
    return response()->noContent(); // 204
});

// NOT idempotent POST — creates new resource each time
Route::post('/orders', function (CreateOrderRequest $request) {
    $order = Order::create($request->validated());
    return response()->json($order, 201); // new order every call!
});

// Making POST idempotent with idempotency keys
Route::post('/payments', function (PaymentRequest $request) {
    $key = $request->header('Idempotency-Key');
    if (!$key) return response()->json(['error' => 'Idempotency-Key header required'], 422);

    // Check if we've seen this key before
    $cached = \Cache::get("idempotency:{$key}");
    if ($cached) return response()->json($cached['body'], $cached['status']); // replay!

    // Process payment
    $payment = Payment::create([
        'amount'     => $request->amount,
        'user_id'    => auth()->id(),
        'charge_id'  => app(PaymentGateway::class)->charge($request->amount, $request->token)->id,
    ]);

    $result = ['body' => $payment->toArray(), 'status' => 201];
    \Cache::put("idempotency:{$key}", $result, now()->addDay()); // cache for 24h
    return response()->json($result['body'], 201);
});

// PATCH can be non-idempotent
Route::patch('/accounts/{id}/deposit', function (int $id, Request $request) {
    // Non-idempotent: calling twice deposits twice!
    \DB::table('accounts')->where('id', $id)->increment('balance', $request->amount);
    return response()->noContent();
});

// Made idempotent via SQL atomic + idempotency key
Route::patch('/accounts/{id}/deposit', function (int $id, Request $request) {
    $key = $request->header('Idempotency-Key');
    \DB::table('transactions')->insertOrIgnore([  // no-op if key already exists
        'idempotency_key' => $key,
        'account_id'      => $id,
        'amount'          => $request->amount,
    ]);
    return response()->noContent();
});