Concurrency in Databases Is Tricky Until You Understand This

by Arif Ikhsanudin, Backend Developer

The race condition you didn't know you had

Your API has an endpoint that deducts credits from a user's account before processing a request. The logic is: read the balance, check it's sufficient, deduct the amount, save. In testing, it works perfectly. Under load with the same user making simultaneous requests, the balance goes negative. Two concurrent requests both read the same balance, both pass the check, both deduct. Classic race condition, classic concurrency bug.

This pattern — read, check, write — is the most common source of concurrency bugs in database-backed applications. Understanding why it fails and how to fix it correctly is the foundation of concurrent database programming.

The TOCTOU problem

Time-of-check to time-of-use (TOCTOU): the state you read may change between when you read it and when you act on it. In a database, this gap exists whenever two operations are not atomic.

# Non-atomic — subject to race condition
def deduct_credits(user_id, amount):
    balance = db.query("SELECT credits FROM users WHERE id = %s", user_id)
    if balance < amount:
        raise InsufficientCreditsError()
    db.execute("UPDATE users SET credits = credits - %s WHERE id = %s", amount, user_id)

Two concurrent calls for the same user_id, both reading balance = 100, both checking that 100 >= 50, both deducting 50. Result: balance = 50, but 100 credits were consumed.

Fix 1: Atomic update with constraint check

Move the check into the UPDATE itself and verify the result:

UPDATE users
SET credits = credits - :amount
WHERE id = :user_id AND credits >= :amount;

-- Check affected rows: 0 means insufficient credits

If the WHERE clause prevents the deduction, the UPDATE affects 0 rows. No deduction occurred. This is atomic — the check and the write happen in a single operation with no gap.

def deduct_credits(user_id, amount):
    rows_affected = db.execute(
        "UPDATE users SET credits = credits - %s WHERE id = %s AND credits >= %s",
        amount, user_id, amount
    )
    if rows_affected == 0:
        raise InsufficientCreditsError()

This works without any explicit locking in READ COMMITTED — the UPDATE acquires a row lock, and the condition is evaluated atomically at the time of the lock acquisition.

Fix 2: SELECT FOR UPDATE

When you need to read the value before deciding what to write (more complex business logic), lock the row at read time:

BEGIN;
SELECT credits FROM users WHERE id = :user_id FOR UPDATE;
-- Row is now locked. No other transaction can modify this row until we commit.
-- Your application logic runs here
UPDATE users SET credits = credits - :amount WHERE id = :user_id;
COMMIT;

SELECT FOR UPDATE tells the database: I'm going to modify this row, so lock it now. Other transactions attempting SELECT FOR UPDATE on the same row will wait until our transaction commits or rolls back. Transactions doing plain SELECT (without FOR UPDATE) will see the old value until we commit (in READ COMMITTED) or will see our committed value after we commit.

The tradeoff: this serializes all concurrent access to the locked row. Under high concurrency for the same user, this is a queue — one request at a time. For low-to-moderate concurrency, this is fine. For high-volume scenarios (a hot user hitting the endpoint 1000 times/second), consider counter patterns that don't serialize on a single row.

Fix 3: Optimistic locking

For lower-contention scenarios where conflicts are rare, optimistic locking avoids holding locks:

-- Add a version column to the table
ALTER TABLE users ADD COLUMN version BIGINT NOT NULL DEFAULT 1;

-- Read with version
SELECT credits, version FROM users WHERE id = :user_id;

-- Update only if version hasn't changed
UPDATE users
SET credits = credits - :amount, version = version + 1
WHERE id = :user_id AND version = :read_version AND credits >= :amount;

If another transaction modified the row between our read and write, the version won't match and the UPDATE affects 0 rows. The application retries. This works well when conflicts are infrequent — each transaction usually succeeds on first attempt, and the occasional retry is acceptable.

The UPSERT race condition

A common concurrency bug in UPSERT (insert-or-update) logic:

# Non-atomic UPSERT — race condition between SELECT and INSERT
def get_or_create_user(email):
    user = db.query("SELECT * FROM users WHERE email = %s", email)
    if user:
        return user
    return db.execute("INSERT INTO users (email) VALUES (%s)", email)

Two concurrent calls for the same email both find no user, both try to insert — one gets a unique constraint violation. The fix is database-native UPSERT:

-- PostgreSQL: INSERT ... ON CONFLICT
INSERT INTO users (email, created_at)
VALUES (:email, NOW())
ON CONFLICT (email) DO NOTHING
RETURNING *;

-- If no row returned, fetch the existing one
SELECT * FROM users WHERE email = :email;

Or with ON CONFLICT DO UPDATE (atomic upsert):

INSERT INTO counters (key, value)
VALUES ('page_views', 1)
ON CONFLICT (key) DO UPDATE SET value = counters.value + 1
RETURNING value;

The mental model

Concurrency bugs come from assuming that a sequence of database operations is atomic when it isn't. The solution is always to identify the operation that must be atomic and make it so — either with a single SQL statement that does the check and write together, with SELECT FOR UPDATE to hold locks across multiple operations, or with optimistic locking and retry logic. Any other approach leaves a race condition window.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

When the Client Forgets to Pay You (or Pretends They Did)

It’s awkward, frustrating, and more common than you think. Handling unpaid invoices gracefully can save relationships—and your sanity.

Read more

Testing Spring Boot Applications With Testcontainers — Real Databases, Real Brokers, Real Tests

H2 in-memory databases don't catch PostgreSQL-specific bugs. Mocked message brokers don't verify producer and consumer integration. Testcontainers runs real infrastructure in Docker during tests, eliminating the gap between what passes locally and what breaks in production.

Read more

Employee vs Contractor: The Real Financial Difference

Why that “expensive” contractor rate isn’t as simple as it looks (and why employees aren’t as cheap as they seem)

Read more

What to Do If You’re Always the “Junior” on Every Project

Feeling like the junior on every project can be frustrating. It’s easy to think you’re stuck—but there are ways to break the cycle.

Read more