What is idempotency?
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:
- The user double-clicks the submit button, and two identical POST requests reach your server within 200 ms.
- The network drops the response. The browser never gets a confirmation, so the user clicks again.
- 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. - A webhook arrives more than once. Stripe sends the same
payment_intent.succeededevent 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 |
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.