Insert or Update, Without a Race

I spent three hours last Tuesday staring at a deadlocked transaction log, wondering why my “clean” implementation had just choked a high-throughput payment queue. Most tutorials treat an upsert like a magical, atomic black box—a simple `INSERT … ON CONFLICT` and you’re done. They never mention the subtle, soul-crushing reality of how to write an upsert correctly when you’re dealing with concurrency, heavy contention, or distributed state. You think your logic is bulletproof because the unit tests passed on your local machine, but the real world doesn’t care about your happy path; it cares about the millisecond where two processes decide they both own the same row.

I’m not here to give you a syntax guide or a list of boilerplate commands you could find in five seconds on Stack Overflow. Instead, I want to talk about the edge cases that actually keep backend engineers awake at night. I’m going to show you exactly where the logic fractures, how to handle the inevitable race conditions, and how to build something that doesn’t fall apart the moment production traffic spikes. We’re going to look at the messy, unscripted parts of database integrity that the documentation conveniently forgets to mention.

Table of Contents

Why Sql Upsert Syntax Examples Often Lie to You

Why Sql Upsert Syntax Examples Often Lie to You

Most tutorials treat an upsert like a magic wand. They show you a clean snippet of `postgresql on conflict clause` or a standard `mysql insert on duplicate key update` and imply that once you paste it, the problem is solved. They focus on the syntax, but they ignore the environment. They assume your database is a static, lonely island where only one request exists at a time.

In reality, your application is a chaotic swarm of concurrent processes. You think your logic is sound because it works perfectly on your local machine, but you aren’t accounting for the gap between the “check” and the “act.” This is where the lies live. When you rely on documentation that only shows the happy path, you miss the reality of preventing race conditions in upserts. If two threads decide a row doesn’t exist at the exact same millisecond, they both attempt to insert, and suddenly your “atomic” operation has just thrown a constraint violation straight into your error logs. You aren’t just managing data; you are fighting for database transaction integrity against a clock you can’t see.

Handling Duplicate Keys in Database Without Losing Data

Handling Duplicate Keys in Database Without Losing Data

The problem isn’t just the syntax; it’s the assumption that your application logic and your database are in perfect sync. Most tutorials show you a clean `INSERT` followed by an `UPDATE` if a row exists, but they ignore the gap between those two calls. If two threads attempt that same logic at the same millisecond, you aren’t just dealing with a duplicate key error; you’re facing a broken state. To actually succeed at handling duplicate keys in database environments, you have to move the decision-making process out of your application code and into the engine itself.

This is where you stop playing with “if-then” logic in your backend and start using atomic database operations. Whether you are working with the `postgresql on conflict clause` or a `mysql insert on duplicate key update` statement, the goal is to ensure the check and the write happen as a single, indivisible unit of work. If you don’t wrap the logic into a single statement, you aren’t writing an upsert—you’re just waiting for a race condition to corrupt your ledger.

The Five Ways Your Upsert Will Actually Fail

  • Stop assuming atomicity is a given. If your application logic checks for existence in one query and then issues an insert in another, you haven’t written an upsert; you’ve written a race condition waiting to happen.
  • Watch your unique constraints like a hawk. An upsert is only as reliable as the underlying index; if your constraint is too broad or missing a specific column, the database will happily create duplicates while your code insists everything is fine.
  • Beware the “blind update” trap. Most tutorials show you how to overwrite a row, but they don’t warn you about the side effects—like accidentally wiping out a `created_at` timestamp or overwriting a field that another service was supposed to manage.
  • Audit your partial indexes. If you use filtered indexes to manage your logic, your upsert might skip rows it should be updating simply because they don’t meet the index criteria, leaving your data in a state of silent inconsistency.
  • Test the “nothing changed” scenario. A robust upsert should be idempotent and smart enough to do nothing if the incoming data matches the existing record, preventing unnecessary write-ahead log bloat and trigger execution.

The Lessons You Won't Find in the Documentation

Syntax is a lie if you ignore concurrency; an `ON CONFLICT` clause is useless if you haven’t accounted for the race conditions that turn your “unique” constraints into a wall of deadlocks.

Stop treating upserts as a magic bullet for data integrity; if your application logic and your database constraints aren’t perfectly synced, you’re just automating the creation of corrupted state.

Always design for the failure case first—write your rollback strategy and your error handling before you even touch the `INSERT` statement, because the “happy path” is where the bugs hide.

## The Gap Between Syntax and Reality

“A tutorial will show you the `ON CONFLICT` syntax and call it a day, but it won’t tell you how your transaction isolation levels will turn that elegant one-liner into a deadlock the moment your traffic spikes.”

Pernille Askeland

The Reality of the Upsert

Understanding The Reality of the Upsert.

At the end of the day, an upsert isn’t just a convenient shorthand for “insert or update.” It is a high-stakes operation that sits right at the intersection of your application logic and your database’s integrity constraints. We’ve seen how a naive implementation can fall apart under the slightest bit of concurrency, turning a simple synchronization task into a nightmare of duplicate primary keys or, worse, silent data loss. You have to account for the race conditions, understand your specific database’s locking behavior, and—most importantly—never assume that a single line of SQL handles the edge cases for you. If you aren’t testing for the collision, you aren’t actually testing the logic.

I know it’s tempting to just copy the snippet from the documentation and move on to the next ticket. But after twenty years of fixing the messes left behind by that exact mindset, I’ve learned that the most expensive code is the code you didn’t think through. Don’t just write for the happy path where every request arrives perfectly sequenced. Write for the moment the network hiccups, the connection drops, or two processes collide at the exact same millisecond. When you build with the expectation of failure in mind, you aren’t just writing code; you’re building systems that actually last.

Frequently Asked Questions

How do I handle an upsert when the unique constraint involves multiple columns instead of just a single primary key?

When your unique constraint spans multiple columns—say, a `tenant_id` and a `transaction_ref`—the logic gets brittle fast. You can’t just target a single ID. You have to ensure your `ON CONFLICT` clause explicitly names every column in that composite key. If you miss even one, the database won’t see the collision, and you’ll end up with the exact duplicate rows you were trying to prevent. It’s a silent, creeping failure.

What happens to my application logic if the upsert triggers an unexpected side effect, like an outdated version of a record overwriting a newer one?

That’s where the “last write wins” fallacy kills you. If your upsert doesn’t account for versioning or timestamps, you’re essentially inviting stale data to hijack your state. You might have a user update their profile at 10:00:01, but a delayed process from 10:00:00 hits the database a millisecond later and overwrites it. Suddenly, your application thinks the user is someone they aren’t anymore. Without optimistic locking, your upsert isn’t an update—it’s a regression.

Is there a performance penalty for using heavy `ON CONFLICT` clauses in high-concurrency payment processing loops?

Yes, there is. In a high-concurrency payment loop, `ON CONFLICT` isn’t free. Every time a conflict occurs, the database has to manage row-level locks and index lookups to decide whether to update or skip. When you’re pushing thousands of transactions per second, those micro-delays compound into lock contention. You’ll see your transaction latency spike because threads are sitting around waiting for a lock on a row that’s currently being contested.

About Pernille Askeland

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

Scroll to Top