Company
About Tomosu
Platform
Platform & Agents Indexes How it works Solutions Pricing
Get Started
MCP Server VS Code — Plugin Installation Scan Your Repo — Guide Integrations · GitHub App Integrations · CodeRabbit MCP FAQ
Free Tools
Governance Impact
Resources
Blogs News Download / Free Trial Book a call →
Production Debugging · Concurrency

Why “Check Then Insert” Creates Duplicate Records

Tomosu AI·14 min read·

Two requests arrive a few milliseconds apart. Each one asks the database whether the email address is taken, each one hears “no”, and each one inserts a new user. Nothing threw an error, every test passed, and the table now holds two accounts for one person. This guide explains why the pattern fails, why transactions and SELECT ... FOR UPDATE usually do not save it, and how to fix it for good.

Quick answer

“Check then insert” creates duplicate records because the check and the insert are two separate steps. Two concurrent requests can both run the check before either one inserts, so both see “not found” and both write. The fix is to let the database enforce the rule at write time:

The pattern goes by several names: check-then-act, read-then-write, insert if not exists, get-or-create. It shows up in sign-up flows, “one cart per user” rules, booking systems, API idempotency checks, and ORM helpers that look atomic but are not. If you are dealing with duplicate webhook deliveries specifically, How to Prevent Duplicate Webhook Processing covers the provider side. This post covers the general database race that sits underneath it.

What is a check-then-insert race condition?

A check-then-insert race condition is a bug where code verifies that a row does not exist and then inserts it in a separate step, so another request can insert the same row in between. The check is correct when it runs. It is stale by the time the insert runs. Nothing in the code ties the two statements together, so any concurrency at all is enough to break the rule.

Here is the version almost every codebase has somewhere:

Python · sign-up handlerraces
def register(conn, email, password):
    with conn.cursor() as cur:
        cur.execute("SELECT 1 FROM users WHERE lower(email) = lower(%s)", (email,))
        if cur.fetchone():
            raise EmailTaken(email)            # the check

        pw_hash = hash_password(password)     # ~100 ms of deliberate slowness

        cur.execute(                          # the act, based on a stale answer
            "INSERT INTO users (email, password_hash) VALUES (%s, %s)",
            (email, pw_hash),
        )
    conn.commit()

Password hashing is deliberately slow, so this handler has a race window of around a hundred milliseconds between the check and the insert. A double-click on the sign-up button, a mobile client that retries after a timeout, or a load balancer that retries an idempotent-looking POST all land inside that window.

TWO SIGN-UPS FOR ONE EMAIL, 40 MS APART REQUEST A · FIRST CLICK REQUEST B · SECOND CLICK t1t2t3t4 SELECT … 'ana@x.io' → 0 rows SELECT … 'ana@x.io' → 0 rows INSERT INTO users … → 1 row COMMIT → 201 Created INSERT … → 1 row · COMMIT not started yet hashing password (~100 ms) hashing password (~100 ms) Result: two rows for ana@x.io Both checks were true when they ran. Neither request could see a row the other had not written yet.
The check answers a question about the past. The insert acts in the present. Nothing in between stops a second writer.

The same shape appears whenever code reads state to decide whether it is allowed to write. “Count the bookings, and insert if there is room.” “Look for an active subscription, and create one if there is none.” “See if this idempotency key was used, and process the request if not.” Each is a check-then-insert with a different predicate.

Where does check-then-insert hide in real code?

It is easy to spot in a handler that runs a SELECT and then an INSERT. It is harder to spot when a framework does the check for you, or when the check and the write live in different functions.

PatternWhat it looks likeWhy it still races
Uniqueness validation in the modelRails validates :email, uniqueness: true, a DTO validator, a form ruleIt runs a SELECT before the insert. The Rails guides warn that two connections can still create duplicate records.
ORM get-or-createfind_or_create_by, a hand-written findOrCreate, get_or_create without a constraintImplemented as read, then write. Safe only when a unique index backs it.
“One active X per user”Check for an active subscription, cart, or session, then create oneThe rule applies to a subset of rows, so a plain unique key does not express it and nobody adds a constraint.
Capacity or quotacount(*) < capacity, then insert a bookingEvery concurrent request counts the same number. The last seat is sold several times.
Idempotency checkLook up the request key, process if absent, record it afterA retry that arrives while the first attempt is still running passes the lookup.
Cache-then-databaseCheck Redis or an in-memory set, then insertTwo layers, two windows. A local set is also invisible to other instances.
Hand-rolled upsertSELECT; if found UPDATE, else INSERTTwo writers both take the insert branch. One fails or both succeed, depending on constraints.
Definition

A race window is the time between the check and the write. Anything that runs in that window, such as hashing, validation, a call to another service, or simply waiting for a CPU, makes the window wider and the duplicates more frequent. Moving slow work before the check does not fix the race; it only shrinks the window.

Why do tests almost never catch it?

Tests miss check-then-insert races because they run requests one at a time, against a quiet database, with no retries. A race needs two writers for the same key inside the same few milliseconds, and a test suite rarely creates that. Production creates it constantly:

That is why duplicates often appear “suddenly” after a traffic increase or a deploy that added workers, even though the code has not changed in months. The broader problem of bugs that only show up under real concurrency is covered in Race Conditions That Only Appear Under Production Load.

How do you know you already have this bug?

SignalWhat it usually means
Duplicate rows created milliseconds apartConcurrent requests: a double submit or two workers racing on the same key.
Duplicates created seconds apart, same clientA client or proxy retry after a timeout, while the first attempt was still running.
MultipleObjectsReturned (Django) or NonUniqueResultException (JPA)A lookup that assumes one row found two. The duplicates already exist.
find_by or LIMIT 1 returns different rows on different callsDuplicates are hidden by queries that silently pick one. Totals and joins are probably wrong.
Deadlock errors on an insert path (MySQL 1213)Someone added SELECT ... FOR UPDATE to the check. The race turned into deadlocks instead of duplicates.
SQL · find duplicates and their timing
SELECT lower(email)                         AS email_key,
       count(*)                             AS copies,
       max(created_at) - min(created_at)    AS spread,
       array_agg(id ORDER BY created_at)   AS ids
FROM   users
GROUP BY lower(email)
HAVING count(*) > 1
ORDER BY spread;

The spread column is the fastest diagnostic. A spread of a few milliseconds is a concurrent race. A spread of seconds is a retry. A spread of days is a different bug: the check itself is wrong, for example comparing emails case-sensitively.

Doesn’t a transaction or SELECT FOR UPDATE prevent it?

Usually not. A transaction makes a group of statements commit or roll back together. It does not, at the default isolation level, stop two transactions from reading the same missing row and both deciding to insert it. This is the most common wrong fix for check-then-insert, so it is worth being precise.

TWO CONCURRENT CHECK-THEN-INSERTS, FOUR GUARDS PG · READ COMMITTED + FOR UPDATE 0 rows match, so 0 rows are locked. Both transactions run their INSERT. PG · SERIALIZABLE SSI Detects the read/ write dependency. One transaction fails with 40001. MYSQL INNODB · RR + FOR UPDATE Both take gap locks, which are compatible. Inserts then wait on each other: 1213. UNIQUE INDEX any isolation level Second INSERT waits for the first commit, then gets 23505 or an ON CONFLICT no-op. Duplicate rows Safe with retry One rolled back Exactly one row Locks protect rows that exist. A duplicate is a row that does not exist yet, so the guard has to live in an index.
Only the unique index gives a correct result on its own. The other guards turn duplicates into errors that your code must retry or report.

The PostgreSQL behavior is documented in Transaction Isolation. If you want to see how often other developers hit exactly this, the Stack Overflow questions on SELECT FOR UPDATE and insert-if-not-exists are a long list of the same surprise.

You cannot lock a row that does not exist yet. The only thing every writer has to pass through is the index.

How do you fix check-then-insert?

Fix check-then-insert by moving the rule into the database and making the insert itself the check. Declare the rule as a constraint, try the write, and treat “already exists” as a normal outcome rather than a pre-condition. The database is the only component that sees every writer, on every instance, at the moment each one writes.

Step 1: declare the rule as a unique constraint

SQL · migrationthe guard
-- Case-insensitive uniqueness, because that is what the rule actually means
CREATE UNIQUE INDEX CONCURRENTLY users_email_lower_key
    ON users (lower(email));

Normalize the key the way the business rule does. A unique index on email will happily accept Ana@x.io and ana@x.io. Also check how nullable columns behave: PostgreSQL treats NULLs as distinct in a unique index by default, so several rows with a NULL key are allowed unless you use NULLS NOT DISTINCT (PostgreSQL 15 and later).

Step 2: insert first, then read on conflict

INSERT FIRST: THE WRITE IS THE CHECK ONE STATEMENT INSERT … ON CONFLICT DO NOTHING RETURNING id Row returned? YES Created This request owns it NO SELECT id … same key sees the committed row Already existed Return it, or 409 IF A RACER HOLDS THE SAME KEY The INSERT waits for its transaction. Commit: conflict. Rollback: insert. No window: the existence test and the write are one atomic operation on the index. The follow-up SELECT runs as a new statement, so under READ COMMITTED it sees the winner’s row.
The insert-first pattern. Losing the race is a normal branch that returns the existing row, not an error page.
Python · PostgreSQLno race window
def register(conn, email, password):
    pw_hash = hash_password(password)        # slow work first; it no longer matters
    with conn.cursor() as cur:
        cur.execute(
            """INSERT INTO users (email, password_hash)
               VALUES (%s, %s)
               ON CONFLICT ((lower(email))) DO NOTHING
               RETURNING id""",
            (email, pw_hash),
        )
        row = cur.fetchone()
    conn.commit()
    if row is None:
        raise EmailTaken(email)               # the index decided, not a stale SELECT
    return row[0]

The double parentheses in ON CONFLICT ((lower(email))) are required: the conflict target is a list, and an index expression inside it needs its own parentheses. The target must match a unique index; see the PostgreSQL INSERT documentation for the full rules. For get-or-create, where you want the existing row back, add a second statement:

SQL · get-or-create, safe under READ COMMITTED
INSERT INTO carts (user_id) VALUES ($1)
ON CONFLICT (user_id) DO NOTHING
RETURNING id;

-- No row returned: the cart already existed, or a racer just committed it.
SELECT id FROM carts WHERE user_id = $1;

If you cannot use ON CONFLICT, the same logic works by catching the error. Do the insert, catch the unique violation (SQLSTATE 23505 in PostgreSQL, error 1062 in MySQL), and read the existing row. In PostgreSQL, any error aborts the surrounding transaction, so wrap the insert in a savepoint if the transaction must continue afterwards. In MySQL, prefer INSERT ... ON DUPLICATE KEY UPDATE over INSERT IGNORE, because IGNORE also downgrades unrelated errors, such as truncated values, to warnings.

Step 3: pick the right guard when the rule is not a plain key

WHICH DATABASE GUARD FITS THE RULE? Q1 · SHAPE OF THE RULE At most one row per key? Q2 · SCOPE Only for some rows (active, not deleted)? Q3 · RANGES No two rows may overlap in time or space? Q4 · COUNTS A count or capacity limit (seats, quota)? Unique constraint plus insert-first or ON CONFLICT Partial unique index UNIQUE (user_id) WHERE active Exclusion constraint PostgreSQL EXCLUDE USING gist Conditional UPDATE on a counter, or lock the parent YESYESYESYES NONONONO Otherwise: lock a row that always exists, or take pg_advisory_xact_lock on the key.
Most check-then-insert rules map to a database feature that enforces them. Locks are the fallback, not the first choice.
SQL · guards for the other shapes
-- "One active subscription per user"
CREATE UNIQUE INDEX one_active_subscription
    ON subscriptions (user_id) WHERE status = 'active';

-- "No overlapping bookings for a room" (during is a tstzrange)
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE bookings ADD CONSTRAINT no_overlapping_bookings
    EXCLUDE USING gist (room_id WITH =, during WITH &&);

-- "No more than capacity": the UPDATE is the check
UPDATE events SET seats_taken = seats_taken + 1
WHERE  id = $1 AND seats_taken < capacity
RETURNING seats_taken;          -- 0 rows: sold out; insert the booking only if 1 row

-- Fallback: serialize on the key itself, released at COMMIT or ROLLBACK
SELECT pg_advisory_xact_lock(hashtextextended('signup:' || lower($1), 0));

The conditional UPDATE works because a row-level write lock serializes concurrent updates to the same event row: the second UPDATE waits, then re-evaluates seats_taken < capacity against the new value. Locking a parent row with SELECT ... FOR UPDATE works for the same reason: unlike the missing child row, the parent exists and can be locked. Advisory locks work too, but every code path that writes the key must remember to take the same lock, which is exactly the kind of convention that erodes over time.

RuleDatabase-enforced fixCode change
One row per keyUnique index on the normalized keyON CONFLICT DO NOTHING, then read; or catch 23505 / 1062
One active row per userPartial unique index WHERE status = 'active'Handle the conflict as “already active”
Soft-deleted rows must not blockPartial index WHERE deleted_at IS NULLUndelete or re-create explicitly
No overlapping rangesExclusion constraint (PostgreSQL)Map the violation to a “slot taken” response
Capacity or quotaConditional UPDATE on a counter rowInsert the child row only when the update returned a row
Side effect in another systemUnique idempotency key in your databaseRecord the key with the state change; pass it to the provider

Are get_or_create and find_or_create_by safe?

They are safe only when a unique constraint backs the lookup fields. Both helpers read before they write, so without a constraint two concurrent calls can both create. With a constraint, the second create fails and a well-built helper recovers by reading the winner’s row.

Ruby · Railsneeds the index
# db/migrate: the guard
add_index :carts, :user_id, unique: true

# Races without the index: SELECT, then INSERT
Cart.find_or_create_by(user_id: user.id)

# Inserts first; on RecordNotUnique, finds the existing cart
Cart.create_or_find_by(user_id: user.id)
Keep the friendly check, but do not trust it

A uniqueness validation still has value: it gives a clear form error in the common, non-concurrent case. Keep it if you like. Just make sure the code path that handles the database’s unique violation returns the same user-facing response, because in production that path will run.

How do you add a unique constraint when duplicates already exist?

You clean up first, then build the index without blocking writes. A unique index cannot be created while duplicate keys exist, so the rollout is a short procedure rather than a one-line migration:

  1. Find the existing duplicates. Group by the normalized key and list every key with more than one row, as in the query above.
  2. Merge or remove them. Pick a surviving row per key, re-point foreign keys and child rows to it, and delete or archive the others. This is a product decision as much as a technical one, so agree on the survivor rule first.
  3. Build the unique index without blocking writes. In PostgreSQL, CREATE UNIQUE INDEX CONCURRENTLY avoids a long write lock but cannot run inside a transaction block. If it fails, for example because a new duplicate slipped in, it leaves an INVALID index behind: drop it, fix the rows, and run it again.
  4. Switch the code to insert first. Replace the SELECT-then-INSERT with ON CONFLICT, or with an insert that catches the unique violation.
  5. Keep the check only as a friendly error. The database error is now the real answer.
PostgreSQL · rollout
-- Must run outside BEGIN/COMMIT (many migration tools need a "no transaction" flag)
CREATE UNIQUE INDEX CONCURRENTLY users_email_lower_key ON users (lower(email));

-- If the build failed, the index exists but is not valid:
SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;
DROP INDEX CONCURRENTLY users_email_lower_key;   -- then fix the data and retry

An expression index such as lower(email) enforces uniqueness on its own; you do not need to convert it to a table constraint. ALTER TABLE ... ADD CONSTRAINT ... UNIQUE USING INDEX only accepts plain column indexes, not expression or partial ones. The details are in the CREATE INDEX documentation. Deploy the index before the code change that relies on it; Missing Database Indexes in a PR covers how reviewers can check that ordering.

What should a reviewer look for in a pull request?

A check-then-insert rarely looks wrong in a diff. It looks like careful code: the author checked first. The review question is whether the database enforces the same rule the code checks. Walk the diff for these patterns:

The pattern
  • A read that decides whether a write is allowed
  • exists, count, find_by, or a cache lookup before an insert
  • A get-or-create or hand-written upsert
  • A uniqueness validator with no index behind it
The guard
  • A unique, partial, or exclusion constraint in a migration
  • The constraint matches the rule’s normalization
  • The migration ships before, or with, the code
  • No FOR UPDATE on a query that can return zero rows
The losing branch
  • The unique violation is caught and mapped to a response
  • The transaction is not left aborted (savepoint or ON CONFLICT)
  • Retries of the same request get the same result
  • A test runs two writers concurrently

This fits into the broader review practice in How to Review a Pull Request for Production Reliability Risks. A useful habit is to write one concurrency test per uniqueness rule: start two threads or two connections, release them from a barrier at the same moment, and assert that exactly one row exists afterwards. It will not catch every interleaving, but it catches the missing constraint every time.

How Tomosu helps

Tomosu analyzes a repository and each pull request for production reliability risks, and check-then-insert is a pattern where the evidence lives in more than one file: the handler that checks, the model or repository that writes, and the migration that does or does not declare the constraint. For this problem, Tomosu:

Those findings feed the Production Reliability Index alongside the other signals for the change. To try it on your own code, follow the repository scan guide.

Scan your repository with Tomosu →

Key takeaways

Frequently asked questions

What is a check-then-insert race condition?

It is a bug where code first checks that a row does not exist and then inserts it as a separate step. Two concurrent requests can both run the check before either inserts, so both see “not found” and both insert. The result is duplicate records, even though each request followed the rule correctly.

Does wrapping the check and insert in a transaction prevent duplicates?

Not at the default isolation level. Under READ COMMITTED in PostgreSQL, or REPEATABLE READ in MySQL without locking reads, both transactions can read “no row” and both insert. A transaction groups statements for atomicity; it does not stop two transactions from reading the same missing row. Only a unique constraint, a lock on something that already exists, or SERIALIZABLE isolation with retries closes the race.

Why does SELECT ... FOR UPDATE not stop duplicate inserts?

FOR UPDATE locks the rows the query returns. When the row does not exist yet, PostgreSQL locks nothing, so both transactions continue. MySQL InnoDB takes gap locks for the missing range under REPEATABLE READ, but gap locks do not conflict with each other, so both transactions proceed and their inserts then deadlock, and one is rolled back.

Is Django get_or_create safe from race conditions?

Only when the database enforces uniqueness on the lookup fields. Django’s documentation says get_or_create is atomic assuming the database enforces uniqueness of the keyword arguments; without a unique constraint, concurrent calls can insert duplicate rows. With the constraint, a racing create raises IntegrityError and Django falls back to fetching the existing row.

What is the difference between find_or_create_by and create_or_find_by in Rails?

find_or_create_by runs a SELECT and then an INSERT, so two concurrent calls can both insert. create_or_find_by, added in Rails 6, inserts first and, if a unique constraint raises RecordNotUnique, finds the existing row. It is only safe when that unique index exists.

How do I add a unique constraint when duplicates already exist?

Find the duplicates with a GROUP BY on the normalized key, merge or delete them and re-point foreign keys, then build the index. In PostgreSQL, CREATE UNIQUE INDEX CONCURRENTLY builds it without blocking writes but cannot run inside a transaction, and a failed build leaves an invalid index that you must drop before retrying.

What if the rule cannot be expressed as a unique key?

Use the database feature that matches the rule: a partial unique index for “one active row per user”, an exclusion constraint for non-overlapping time ranges, or a conditional UPDATE on a counter for capacity limits. When none fit, lock a parent row that always exists, or take a transaction-scoped advisory lock on the key.


Duplicate records are rarely a data problem. They are a rule that lived in application code and never reached the database. Tomosu helps you find those rules before production does. Assess your repository →