Blog
A Unique Index Beats Your If Statement
- laravel
- typescript
- mysql
- databases
- concurrency
Two requests hit "create account" at the same time. Both check whether the email is taken. Both see nothing. Both insert. Now you have two users with the same email, and whichever one you look up first depends on how the query is feeling that day.
Nobody wrote a race on purpose. They wrote an if statement that looked careful. The database never got a chance to disagree.
Two steps is the bug
The pattern shows up everywhere:
- Look up a row by some natural key (email,
external_id, slug, order number). - If it is missing, insert it.
Between step 1 and step 2, another request can do the same thing. Your application code is not a lock. Two workers, two PHP-FPM processes, two Node instances, it does not matter. They both read "missing" and both write.
So the if statement is a hint for the happy path. It is not the guard. The guard is a unique index on that key.
Put the constraint in the schema
In Laravel, say it in the migration, not in a comment:
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('email')->unique();
$table->string('external_id')->nullable()->unique();
$table->timestamps();
});In SQL it is the same idea:
CREATE UNIQUE INDEX users_email_unique ON users (email);If the column is nullable and you only want uniqueness among real values, know how your database treats NULL. In MySQL, multiple NULLs usually pass a unique index. That is fine for "optional external id," and a footgun if you meant "at most one empty." Do not leave that as a surprise.
Handle the duplicate like a real outcome
Once the index exists, the second insert fails. That failure is not an unexpected outage. It is the system telling you someone else got there first.
Laravel's firstOrCreate reads first and inserts second, so it has the same gap. With a unique index, the loser of the race gets a QueryException instead of a duplicate row. That's better, but it's still an exception. Newer Laravel versions also have createOrFirst, which flips the order: it tries the insert, and if the unique index rejects it, it reads the row that won.
$user = User::createOrFirst(
['email' => $email],
['name' => $name]
);Either way, the unique index is doing the real work. Without it, two callers can both miss and both create.
When you care about the conflict yourself (show a validation error, return the existing row, merge), catch the duplicate and decide:
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
try {
return DB::transaction(function () use ($email, $name) {
return User::create([
'email' => $email,
'name' => $name,
]);
});
} catch (QueryException $e) {
if ($e->getCode() !== '23000') {
throw $e;
}
return User::where('email', $email)->firstOrFail();
}23000 is the SQLSTATE for integrity constraint violation. Check the driver message or SQLSTATE rather than matching English text. And after a conflict, read the row that won. Do not assume your in-memory copy is the one that landed.
TypeScript: upsert when the database supports it
With Prisma, upsert is the tidy version, as long as the field in where is actually unique in the schema:
const user = await prisma.user.upsert({
where: { email },
create: { email, name },
update: { name },
});One catch: Prisma only turns that into a native database upsert when the query meets its conditions (roughly, one unique field in where, the same value in create, and no nested writes). Otherwise it does a read and a write, and a concurrent caller can still get a unique constraint error (P2002). So handle that error too, or check the query log to see what it actually ran.
With raw SQL on Postgres, ON CONFLICT puts the whole thing in one statement:
await db.query(
`INSERT INTO users (email, name)
VALUES ($1, $2)
ON CONFLICT (email) DO UPDATE
SET name = EXCLUDED.name
RETURNING *`,
[email, name]
);MySQL's INSERT ... ON DUPLICATE KEY UPDATE works too. The important part is not which dialect you like. It is that the uniqueness lives in the engine, not in your if.
If you only have an ORM find plus create, wrap the create in a try/catch for the duplicate key error and fall back to a find. Same shape as the Laravel example. Same requirement: the unique index has to exist, or you are still guessing.
"But I checked in a transaction"
A transaction does not serialize those two steps unless you ask for a lock. Two transactions can both SELECT under the default isolation level, both see nothing, and both INSERT. One of them then hits the unique index and rolls back. That is the index saving you, not the transaction.
If you are updating based on a read (inventory, counters, "claim this row"), use a real lock or an atomic update:
$updated = DB::table('jobs')
->where('id', $id)
->whereNull('claimed_at')
->update(['claimed_at' => now(), 'worker' => $worker]);
if ($updated === 0) {
// someone else claimed it
}That is still "one statement the database evaluates," not "read, think, write."
Validation is for people. Indexes are for races.
A unique validation rule in Laravel is good UX. It gives a clean error before you attempt the insert. It does not replace the index. Two slow form posts, two API clients, a retry storm from a mobile app, and the validation check is back in the same race.
Do both. Validate so humans get a nice message. Index so the database refuses the impossible row when the nice path is not enough.
Make the impossible unrepresentable
If the business rule is "one row per email" or "one row per (tenant_id, slug)," that rule belongs in the schema. Application checks are how you explain the failure. They are not how you enforce it.
Add the unique index. Treat duplicate-key as a branch you expected. Stop asking an if statement to do a concurrency job it cannot finish.