What is idempotency?

Free preview 8 min to read

The light switch and the elevator button

You walk into a dark room and flip the light switch up. The light turns on. You flip it up again, and nothing changes, because the light was already on. You can flip it up fifty times and the result is the same: the light is on.

Now consider an elevator button. You press "3" and the elevator starts moving. You press "3" again. Does it go to floor 3 twice? No. The elevator recognized your intent and ignored the duplicate press.

Both are idempotent operations. Doing them once has the same outcome as doing them many times, and the system reaches the same state however often the action is performed.

In software, idempotency means that calling an operation once produces the same result as calling it two, three or a hundred times. The system doesn't break, duplicate or corrupt anything when the same request arrives more than once.

Why this matters in Rails

In a perfect world, every request to your Rails application happens once. The user clicks "Pay now", the server processes it, and the response arrives.

The real world is messier:

  1. The user double-clicks the submit button, and two identical POST requests reach your server within 200 ms.
  2. The network drops the response. The browser never gets a confirmation, so the user clicks again.
  3. A background job runs again. A job that raised and is retried with retry_on, or one that a worker released while shutting down during a deploy, runs a second time even if the first attempt already did part of the work.
  4. A webhook arrives more than once. Stripe sends the same payment_intent.succeeded event again when your server doesn't answer with a 2xx in time.

If a payment service isn't idempotent, the user is charged twice. If an enrollment service isn't, the learner is enrolled twice and earns double XP. If an email service isn't, the user gets three welcome emails. These things happen in production, at every scale.

The mathematical definition

Idempotency comes from mathematics. A function f is idempotent if:

f(f(x)) = f(x)

Applying the function twice gives the same result as applying it once. In programming terms:

# Idempotent: calling it twice produces the same state
user.update!(active: true)
user.update!(active: true)  # No change — active was already true

# NOT idempotent: calling it twice doubles the effect
user.update!(balance: user.balance + 100)
user.update!(balance: user.balance + 100)  # Balance increased by 200!

The first example sets an absolute value, active = true. However many times you run it, active is true. The second uses a relative operation, balance + 100, and each call adds another 100, so the outcome depends on how many times it runs.

That's the core distinction: absolute operations are naturally idempotent, and relative operations aren't.

Idempotent and non-idempotent operations in Rails

Here is how that maps to common Rails calls:

Operation Idempotent? Why
User.find_or_create_by(email: '[email protected]') Yes Creates once, finds afterwards
User.create!(email: '[email protected]') No Creates a duplicate, or raises on the second call
user.update!(role: :admin) Yes Sets an absolute value, the same every time
user.increment!(:login_count) No Adds 1 each time, so the count keeps growing
Enrollment.find_or_create_by(user: u, course: c) Yes Enrolls once, finds afterwards
ExperiencePoint.create!(user: u, amount: 10) No Creates a new XP record each time

Operations that set state are idempotent. Operations that append or increment aren't.

In this application, that logic lives in service objects. Here's a simplified cut of the real Enrollment::CompleteLessonService; the full version also awards XP, updates streaks and checks badges, and a later lesson shows all of it:

module Enrollment
  class CompleteLessonService
    def self.call(user:, lesson:)
      completion = ::Enrollment::LessonCompletion.new(user: user, lesson: lesson)

      # A real UNIQUE index on (user_id, lesson_id) makes this idempotent —
      # the second call's save either succeeds once or fails safely, never duplicates
      completion.save
      # ...
    end
  end
end

Calling CompleteLessonService.call(user: alice, lesson: lesson_1) ten times produces one LessonCompletion record, never two. The first call creates it, and the database's UNIQUE index makes every later call a safe no-op.

Whether a no-op call reports success or failure is a design choice. This app's service reports it as a failure, so the caller can tell that nothing new happened. The database-level lesson returns to that trade-off.

HTTP methods and idempotency

The HTTP specification defines which methods should be idempotent:

Method Idempotent? Rails convention
GET Yes Read-only; never modifies data
PUT Yes Replaces the whole resource, so the same payload gives the same state
PATCH Depends A partial update, idempotent when it sets absolute values
DELETE Yes Deleting something twice leaves it deleted
POST No Creates a new resource each time

That's why Rails maps create to POST and update to PATCH or PUT. Creating is inherently non-idempotent, because each call makes a new record. Updating is naturally idempotent when it sets absolute values.

Here's the trap: Rails controllers don't enforce idempotency, so your service objects must.

In this app, the enroll button sends a POST to Enrollment::EnrollmentsController#create, which calls Enrollment::StartCourseService. If that POST arrives twice, the controller can't tell. What decides whether you get one enrollment or two is the service and the unique index on (user_id, course_id) behind it.

The three levels of idempotency

There are three levels, and knowing them helps you choose the right approach:

Level 1: Natural Idempotency
├── The operation is inherently safe to repeat
├── Example: user.update!(active: true)
└── No extra code needed

Level 2: Constraint-Based Idempotency
├── A database constraint prevents duplicates
├── Example: UNIQUE index on (user_id, lesson_id)
└── The database enforces it — your code handles the conflict

Level 3: Key-Based Idempotency
├── A unique key identifies each logical operation
├── Example: Idempotency key from the client (Stripe pattern)
└── Your code stores and checks the key before processing

Level 1 is free: some operations are naturally safe, such as setting a boolean, replacing a string or overwriting a timestamp.

Level 2 uses the database as the enforcer. A unique index on (user_id, lesson_id) means PostgreSQL itself rejects a duplicate LessonCompletion. Your service catches the constraint violation and handles it explicitly. Some services return the existing record as a success, and others report a failure so the caller knows nothing new happened. Either way, the data never duplicates.

Level 3 is for operations where the database can't infer uniqueness from the data alone, such as payments, API requests and webhooks. The caller provides a unique key, and the server checks whether that key has already been processed.

This course covers all three, so by the end you'll know which level fits any service object you write.

Idempotency in this architecture

In this app's DDD architecture, idempotency is the service object's responsibility, not the controller's or the model's.

HTTP Request (possibly a retry)
     │
     ▼
 Controller
     │  ✅ Passes params to service
     │  ❌ Does NOT check for duplicates
     │
     ▼
 Service Object
     │  ✅ Checks if operation already happened
     │  ✅ Returns existing result if duplicate
     │  ✅ Processes normally if new
     │  ✅ Returns Result = Data.define(...)
     │
     ▼
 Model + Database
     │  ✅ Enforces constraints (UNIQUE indexes)
     │  ✅ Provides find_or_create_by
     │
     ▼
 Result (same whether first call or tenth)

The controller stays thin: it calls the service and renders the result. The service holds the idempotency logic, and the model and database provide the safety net. Together they make duplicate requests produce duplicate responses, never duplicate side effects.

Common mistakes

The first mistake is assuming POST requests arrive only once. A user double-clicks, a webhook is retried or a client resends, and the service creates duplicate records. The rule: every service object that creates or modifies data must handle being called twice with the same arguments.

The second is making the controller responsible for deduplication. The logic leaks into the controller, and different controllers calling the same service behave differently. The rule: idempotency lives in the service object, never in the controller.

The third is using increment! or create! without an idempotency guard. Counters double-count, ledgers get duplicate entries, and XP is awarded twice. The rule: for append-only records such as XP, transactions and events, use an idempotency key or a unique constraint.

Summary

Concept Key point
Idempotency Calling an operation once gives the same result as calling it many times
Absolute vs relative update!(active: true) is idempotent; increment!(:count) isn't
Why it matters Double-clicks, network retries, jobs that run again and repeated webhooks all cause duplicate calls
HTTP methods GET, PUT and DELETE are idempotent by spec; POST isn't; PATCH depends on the implementation
Three levels Natural (free), constraint-based (UNIQUE index), key-based (idempotency keys)
Where it lives In the service object, with the database as the safety net
Rails pattern find_or_create_by is the simplest idempotent pattern: it creates once and finds afterwards
Lesson 1 of 7 · free

Keep going with lesson 2

The cost of non-idempotent code

Shows how double-clicks, job retries, webhook replays and the find_or_create_by race create silent duplicates in Rails.

A free account also gives you the quizzes, your progress and notes, and the discussion.

Type to start searching...