It is 2:19 a.m.
A customer clicks Pay in your ecommerce store.
Your backend validates the cart, creates an order, reserves inventory, calls the payment provider, and sends a confirmation email.
Then the payment provider times out.
Your request fails.
The customer refreshes the page.
The order is there.
The payment is not.
Or worse: the payment succeeded, but your application never recorded it.
Now support receives a message that says:
“I was charged, but my order says payment failed.”
This is not a database bug.
The database may have done exactly what it was designed to do.
The real issue is architectural: the application treated a multi-step business workflow as if it were one simple database transaction.
ACID—atomicity, consistency, isolation, and durability—gives databases powerful guarantees. But production systems still have retries, timeouts, external APIs, queues, caches, replicas, and users clicking buttons twice.
Understanding ACID is therefore not about memorizing definitions.
It is about knowing what your database protects, what it cannot protect, and where your application must take responsibility.
What ACID actually promises
ACID describes four properties of a transaction:
- Atomicity: A transaction succeeds completely or rolls back completely.
- Consistency: The transaction moves data from one valid state to another valid state.
- Isolation: Concurrent transactions do not interfere in undefined ways.
- Durability: Once a transaction is committed, the transaction survives failures.
These properties are foundational to transactional databases. Atomicity treats multiple operations as one unit; consistency preserves valid states; isolation prevents concurrent transactions from interfering; durability ensures committed changes survive failures.
But production reality adds a fifth concern:
What happens when the transaction succeeds, but the business operation is not complete?
That is where many real incidents begin.
Atomicity: when “all or nothing” becomes complicated
Atomicity means that a transaction is indivisible. Either every operation in it succeeds, or none of its changes are applied.
The classic example
A bank transfer should not deduct money from one account and fail to add it to the other.
If any step fails, the transaction rolls back.
This is atomicity working as intended.
The production problem
Atomicity protects operations inside the database.
It does not automatically protect operations outside it.
Consider this workflow:

The database transaction may commit successfully.
Then the payment provider call fails.
Now you have:
- A committed order.
- Reserved inventory.
- A payment attempt.
- No successful payment.
That may be correct.
But only if the application has a deliberate next step:
- Mark order as awaiting payment
- Retry payment safely
- Release inventory if payment permanently fails
- Notify the user
The problem is not that atomicity failed.
The problem is that the workflow crossed a boundary the database cannot make atomic by itself.
What to design for
For operations involving external systems, define the transaction boundary carefully.
Ask:
- What must be committed together?
- What can safely happen later?
- What happens if the external call fails?
- Can the operation be retried?
- Can it be retried more than once?
- What state should the user see while waiting?
- How will the system recover?
A payment request, for example, should usually be idempotent. OWASP recommends using idempotency keys for external actions such as charging a card and reusing the same key when retrying the same operation.
Atomicity gives you a safe local unit of work.
It does not remove the need for workflow design.
Consistency: the database cannot know your business rules alone
Consistency means a transaction moves the database from one valid state to another valid state, respecting defined rules and constraints.
The database can enforce invariants
A database can enforce rules such as:
These constraints protect the database from invalid data even when application code has a bug.
For example, two concurrent requests may both check whether an email is available before inserting a user. Application-level validation alone can create a race condition.
A unique constraint gives the database the final say.
The production problem
The database can enforce some invariants, but it cannot understand every business rule.
For example, it may know that:
stock >= 0
But it may not know that:
- A customer cannot purchase more than five units during a flash sale.
- A user cannot downgrade a subscription while an invoice is unpaid.
- A tenant cannot exceed its plan limit.
- A refund cannot exceed the amount already captured.
Those rules may require application logic, database constraints, locking, or a combination of all three.
A common mistake: application-only validation
This pattern is risky:
Two requests can read the same stock value, both pass the check, and both reduce stock.
A safer approach is to let the database enforce the invariant atomically:
Then check whether a row was updated.
If no row was updated, the purchase cannot proceed.
What to design for
Separate your rules into layers:
- Database constraints: uniqueness, foreign keys, non-negative values, valid states.
- Application validation: user input, business rules, permissions.
- Concurrency controls: atomic updates, optimistic locking, transactions.
- Workflow safeguards: idempotency, retries, reconciliation.
Consistency is not only a database concern.
It is a system-wide contract.
Isolation: the hardest guarantee to reason about
Isolation determines how concurrent transactions affect one another.
Ideally, concurrent transactions behave as if they ran one after another. In practice, databases offer different isolation levels because stronger isolation often costs more in locking, waiting, retries, or reduced concurrency.
The race condition
Two customers try to buy the last item.
Both requests read:
stock = 1
Both see that stock is available.
Both decrement stock.
Now the system has sold the same item twice.
This is a concurrency problem.
It is not solved by merely wrapping the operations in a transaction if the transaction reads and writes in a way that allows the race.
Isolation levels
Common isolation levels include:
PostgreSQL supports multiple isolation levels and uses concurrency-control mechanisms to implement them. Serializable isolation uses predicate locking to detect when a concurrent write would have affected a previous read.
Stronger isolation can improve correctness, but it can also increase contention.
The production problem
Teams often choose a default isolation level without understanding the consequences.
Then they discover:
- Two users bought the same inventory item.
- A report shows a total that does not match its underlying rows.
- A job processed the same event twice.
- A booking was double-created.
- A balance became negative under load.
The application may have correct business logic in isolation, but incorrect behavior under concurrency.
What to design for
Use guarded updates
Instead of:
Use:
Then verify that exactly one row was affected.
Use unique constraints
For one-time actions, make the database enforce uniqueness:
If the same request arrives twice, the second insert fails or is safely handled.
Use optimistic locking
Add a version column:
- id
- status
- version
An update succeeds only if the version has not changed:
If no row is updated, another transaction changed the record first.
Choose isolation deliberately
Do not assume the default isolation level is correct for every workflow.
For inventory, bookings, payments, and other conflict-sensitive operations, understand the database’s behaviour under concurrency.
Isolation is not a setting you choose once and forget.
It is part of the correctness model.
Durability: committed does not always mean finished
Durability means that once a transaction is committed, its effects persist even if the system crashes or loses power.
The false sense of safety
A commit is durable.
But durability does not mean:
- The email was sent.
- The payment provider processed the charge.
- The cache was updated.
- The search index was updated.
- The background job completed.
- The user saw the result.
A transaction can commit successfully, and an external dependency can fail immediately afterward.
The production problem
Consider this flow:
The database commit succeeds.
Then the email provider is down.
The order is durable.
The notification is not.
That may be acceptable if the notification is retried later.
But if the business requires the email to be sent, the architecture must account for that requirement.
What to design for
Separate durable state from side effects
Store the fact that an action is required, then perform the side effect asynchronously.
For example:
The transaction commits:
- Create order
- Create payment record
- Create outbox event
A worker later processes the event:
- Send email
- Update delivery status
- Retry if necessary
This is the outbox pattern.
It does not make the external email call atomic with the database transaction.
It makes the intent durable, so the system can recover and complete the work later.
Protect backups
Durability also depends on backups.
A replica may replicate accidental deletions or corrupted data.
A backup strategy should include:
- Backup frequency.
- Retention.
- Encryption.
- Access controls.
- Restore testing.
- Recovery-time objectives.
- Recovery-point objectives.
A backup that has never been restored is an assumption, not a recovery plan.
Monitor durability signals
Track:
- Database health.
- Replication lag.
- Disk usage.
- Backup success.
- Restore tests.
- Queue depth.
- Failed jobs.
- Dead-letter messages.
Durability is not only about the moment of commit.
It is about whether the system can recover and continue correctly afterward.
The real production problem: partial success
ACID gives you strong guarantees inside a transaction.
But production systems often involve multiple boundaries:
- Database transaction
- External payment provider
- Email provider
- Message queue
- Cache
- Search index
- Background worker
A request may succeed in one boundary and fail in another.
This creates partial success.
Example: payment succeeds, response fails
- User submits payment
- Application calls payment provider
- Provider charges the card
- Provider response times out
- Application returns error
- User retries
- Application charges again
The user is charged twice.
The database may be perfectly consistent.
The business outcome is not.
Example: order commits, notification fails
The order is correct.
The user experience is broken.
Example: webhook arrives twice
- Payment provider sends webhook
- Server processes it
- Response times out
- Provider retries webhook
- Server processes it again
- Order is marked paid twice
- Duplicate emails are sent
The database may have no duplicate row if the design is careful.
But if it does not, the same event can create duplicate business effects.
What to design for
Idempotency keys
Use a client-supplied or server-generated key to identify the same logical operation.
Idempotency-Key: checkout_123_attempt_1
Store the key with the result.
If the same key arrives again, return the original result instead of repeating the action.
Event deduplication
Store event IDs:
`- processed_events
event_id
processed_at`
Before processing a webhook:Check whether the event ID exists.
If it does, acknowledge it.
If it does not, process it and record it.
Use a transaction or unique constraint to avoid a race between checking and inserting.
State machines
Define valid states and transitions.
`- pending → authorized
- pending → failed
- authorized → captured
- captured → refunded`
Do not allow invalid transitions such as:
failed → refunded
unless the business explicitly allows it.
Reconciliation
For financial systems, periodically compare internal records with provider records.
Detect:
- Payments marked failed locally but successful externally.
- Payments marked successful locally but failed externally.
- Missing webhook events.
- Duplicate charges.
- Refund mismatches.
Reconciliation is not optional for systems that move money.
ACID and distributed systems
ACID is strongest within a single database transaction.
It becomes more complex when work spans multiple databases, services, or providers.
The problem with distributed transactions
A distributed transaction may involve:
- Order database
- Inventory database
- Payment provider
- Notification service
You cannot simply wrap all of them in one local BEGIN ... COMMIT.
Network calls can fail.
Responses can time out.
A service can crash after committing locally.
A message can be delivered twice.
Patterns that help
Outbox pattern
Commit the business change and the event in the same database transaction.
A worker publishes the event afterward.
This avoids the situation where the database commits but the event is never published.
Saga pattern
Break a long workflow into local transactions with compensating actions.
For example:
- Reserve inventory
- Charge payment
- Confirm order
If payment fails:
- Release inventory
- Cancel order
- Notify user
Idempotent consumers
Assume messages and webhooks may arrive more than once.
Use event IDs and idempotency keys.
Reconciliation
Assume that state may diverge.
Compare internal state with external systems periodically.
Observability
Track:
- Transaction duration.
- Lock waits.
- Deadlocks.
- Retries.
- Queue depth.
- Dead-letter messages.
- External provider failures.
- Replication lag.
You cannot fix what you cannot see.
A practical checklist
Before writing a transaction
Ask:
- What must succeed together?
- What can safely happen later?
- What happens if this fails halfway?
- Can this operation be retried?
- Can it be retried more than once?
- What external systems are involved?
- What state should the user see while waiting?
- How will the system recover?
For concurrency
Ask:
- Can two users perform this action at the same time?
- Can two requests read the same value before either writes?
- Does the database enforce uniqueness?
- Is the update guarded?
- Do I need optimistic locking?
- Do I need a stronger isolation level?
- What happens under contention?
For durability
Ask:
- Is the commit enough?
- What side effects follow the commit?
- How will failed side effects be retried?
- How will the system recover after a crash?
- Are backups tested?
- Are replicas monitored?
- Are dead-letter queues handled?
For financial workflows
Ask:
- Is every payment action idempotent?
- Are webhook signatures verified?
- Are duplicate events ignored?
- Are state transitions valid?
- Is reconciliation scheduled?
- Are audit records complete?
- Can support investigate a failed payment?
Common mistakes
Treating ACID as a silver bullet
ACID protects database transactions.
It does not automatically protect distributed workflows.
Wrapping external calls in database transactions
A long external API call inside a transaction can hold locks, exhaust connections, and reduce throughput.
Keep transactions short where possible.
Relying only on application checks
Application validation is important, but database constraints and atomic updates protect against race conditions.
Assuming a commit means the workflow is complete
A commit is durable, but external side effects may still fail.
Retrying without idempotency
Retrying a payment, order creation, or webhook without an idempotency key can create duplicate business effects.
Using the default isolation level blindly
Different workflows have different concurrency requirements.
Understand what your database guarantees at each level.
Ignoring dead-letter queues
A failed job should not disappear silently.
It should be visible, retry-able, and diagnosable.
Not testing failure paths
Test:
- Timeouts.
- Retries.
- Duplicate requests.
- Concurrent updates.
- Provider failures.
- Database crashes.
- Replication lag.
- Queue failures.
Final thoughts
ACID is not a checklist you complete once.
It is a lens for understanding how your system behaves when reality intervenes.
Atomicity tells you what must succeed together.
Consistency tells you what valid states look like.
Isolation tells you how concurrent actions interact.
Durability tells you what survives failure.
But production adds another layer:
What happens when the database succeeds and the rest of the system does not?
That is where idempotency, retries, state machines, outbox patterns, sagas, reconciliation, and observability become necessary.
The goal is not to avoid ACID.
It is to understand its boundaries.
Because a database can guarantee that a transaction is atomic, consistent, isolated, and durable.
It cannot guarantee that your payment provider responded, your email was delivered, your queue was processed, or your user did not click “Pay” twice.
Those are architectural problems.
And they are the problems that separate a system that works in development from a system that survives production.











Top comments (0)