Checking Then Inserting Is Not Atomic

I was staring at a flickering terminal at 2:00 AM, watching a payment reconciliation script throw a flurry of errors that made absolutely no sense on paper. The logic was sound, the unit tests were passing, and the documentation promised everything was fine. But in the real world—the one where thousands of transactions hit a database simultaneously—the gap between theory and reality became a canyon. Most tutorials treat unique constraints and race conditions as a minor edge case you can handle with a simple `if` statement in your application code, but they don’t tell you about the silent data corruption that happens when your code thinks it’s safe, but the database is already moving on to the next instruction.

I’m not here to walk you through the “happy path” where everything works exactly as the documentation says it should. I’ve spent twenty years cleaning up the mess left behind when developers assume that checking for existence before an insert is enough to prevent a collision. Instead, I’m going to show you how to build defensive, database-level safeguards that actually hold up under pressure. We are going to look at what happens when the collision actually occurs and how to write code that expects the failure rather than being surprised by it.

Table of Contents

Why Your Database Fails at Preventing Duplicate Record Insertion

Why Your Database Fails at Preventing Duplicate Record Insertion

The tutorial shows you the `INSERT` statement and tells you it works. What it doesn’t tell you is that your application code lives in a different world than your database. When I’m looking at a legacy payment service, I often see this pattern: the code checks if a transaction ID exists, sees nothing, and then sends the command to create it. It feels logical, but it’s a lie. Between that check and the actual write, there is a gap—a tiny, silent window where another thread can slip in and do the exact same thing.

This is where most developers lose their minds. You think you have a safety net, but you’re actually relying on concurrency control mechanisms that aren’t configured for the reality of high-traffic loads. Even if you think you’ve handled the logic in the application layer, the database is the only source of truth. If you aren’t explicitly preventing duplicate record insertion at the schema level, you aren’t actually preventing it; you’re just hoping the timing works out in your favor. And in my twenty years of maintaining these systems, I’ve learned that timing never works out in your favor.

The Chaos of Concurrency Control Mechanisms in Production

The Chaos of Concurrency Control Mechanisms in Production

In production, the theory of concurrency control mechanisms usually hits a wall of reality when you actually look at your logs. You might think you’ve implemented a solid strategy using optimistic vs pessimistic locking, but the reality is often a mess of deadlocks and timed-out requests. I’ve spent far too many late nights debugging services where the application layer thought it had a lock, but the database was already moving on to the next instruction. It isn’t just about a failed query; it’s about the cascading latency that follows when your connection pool starts choking on stalled processes.

The real headache often stems from a misunderstanding of database transaction isolation levels. You might be running on Read Committed thinking you’re safe, only to realize that your “check-then-insert” logic is wide open to a race condition between the select and the write. People often try to patch this by switching to a heavy-handed `SELECT … FOR UPDATE`, but that’s just trading one problem for another. Suddenly, your throughput drops off a cliff because you’ve turned your highly concurrent system into a single-file line.

How to stop the bleeding before it hits production

  • Stop relying on application-level checks. Checking `if (exists)` in your code before an `INSERT` is a lie you tell yourself to sleep better; by the time the next line executes, the state has already changed.
  • Let the database be the final arbiter. A unique constraint at the schema level is the only thing that actually works when the race starts; if it isn’t in the DDL, it doesn’t exist.
  • Design your error handling for the failure, not the success. Don’t just catch a generic exception; specifically trap the unique violation error so you can decide whether to retry the transaction or tell the user they’ve already done this.
  • Use idempotency keys for everything. If you’re handling payments, every request needs a client-generated token that the backend uses to ensure that even if the network retries a “successful” but timed-out request, you don’t charge the customer twice.
  • Watch your isolation levels. Moving from Read Committed to Serializable might solve your race condition, but it’ll introduce serialization failures that will break your service if your retry logic isn’t rock solid.

Lessons from the Trenches

A unique constraint is not a suggestion; if you aren’t enforcing it at the database level, you aren’t actually preventing duplicates, you’re just hoping they don’t happen.

Application-level checks are a lie told by tutorials; they create a false sense of security that evaporates the moment two requests overlap in the millisecond between your “check” and your “insert.”

Debugging race conditions requires looking at the logs of what happened simultaneously, not just looking at the state of the data after the damage is already done.

## The Gap Between Logic and Reality

A unique constraint is a promise your database makes to your code, but a race condition is the moment you realize that promise only holds if you aren’t standing in the middle of a high-concurrency storm.

Pernille Askeland

Beyond the Happy Path

Enforcing database state Beyond the Happy Path.

We’ve seen that a unique constraint isn’t a magic shield; it’s a specific tool that requires the right context to function. Relying on application-level checks is a recipe for disaster because, as we discussed, the gap between your “if exists” check and your actual “insert” statement is where the race condition lives. You can’t outrun the speed of a concurrent request with better logic alone; you have to lean on the database’s atomic guarantees. Whether you choose strict constraints, isolation levels, or explicit locking, the goal is the same: stop trying to manage state in the air and start enforcing it at the source.

Maintaining these systems for twenty years has taught me that the most dangerous code isn’t the stuff that crashes—it’s the stuff that appears to work while quietly corrupting your data integrity in the background. Don’t be satisfied with a tutorial that only shows you the successful `201 Created` response. Build for the collision. Build for the millisecond where everything goes wrong. When you design for the failure modes, you aren’t just writing code; you are crafting resilience into the very foundation of your system.

Frequently Asked Questions

If I've already implemented a unique constraint, why am I still seeing duplicate entries in my application logs?

It usually comes down to where you think the “truth” lives. You probably checked for existence in your application code before attempting the insert—the classic `if (!exists) { save() }` pattern. That’s a logic trap. Between that check and the actual write, another thread has already slipped through. Your logs are screaming because your application thinks it’s being careful, but it’s actually just creating a window for the race to happen.

At what point does adding more indexes to prevent these collisions start hurting my write performance more than the race condition itself?

You hit the wall when your write latency starts climbing predictably every time you add a new index. In payment processing, I’ve seen it happen: you add an index to catch a specific edge case, and suddenly your transaction throughput drops by 15%. It’s a balancing act. If your index overhead slows down your ingestion more than the cost of handling a few retries in the application layer, you’ve gone too far.

When should I stop relying on the database to catch these errors and start handling the logic at the application level with distributed locks?

You stop relying on the database when the cost of a collision becomes higher than the cost of the lock. If a duplicate entry just means a slightly annoyed user, let the DB throw the error. But if that race condition triggers a double payout or corrupts a ledger, you can’t wait for a constraint to fail. That’s when you move the logic to a distributed lock. You trade raw throughput for the certainty that the error never happens in the first place.

About Pernille Askeland

I write the part the tutorial skipped: what it looks like when it breaks.

Scroll to Top