Skip to main content
David Dew Mallick

David Dew Mallick

ProjectsExperienceSkillsBlog
Back to blog
eloquentupsertmysqlbulk insert

Eloquent upsert and updateOrCreate at Scale

Compare Eloquent upsert, updateOrCreate, and insertOrIgnore on MySQL and PostgreSQL, including race conditions, deadlocks, and safe bulk imports.

Oct 7, 2026 · 7 min read

A nightly import runs 400,000 rows through Eloquent, and for the first time two workers overlap. Rows that should have been updated get inserted twice, the unique index throws, and the job dies halfway. The fix is rarely a bigger timeout; it is choosing the right write primitive. This guide compares an eloquent upsert with updateOrCreate and insertOrIgnore, and shows which one survives concurrent writers.

What does an Eloquent upsert actually do?

An eloquent upsert compiles to a single INSERT ... ON DUPLICATE KEY UPDATE statement on MySQL and INSERT ... ON CONFLICT ... DO UPDATE on PostgreSQL. It sends every row in one query, so the database resolves conflicts atomically per statement. The second argument lists the columns to match on, and the third lists the columns to overwrite.

The critical constraint is that the conflict columns must have a unique or primary key index. Without one, MySQL runs the update against whatever row it finds, and PostgreSQL rejects the statement with there is no unique or exclusion constraint matching the ON CONFLICT specification. Verify with:

SHOW INDEX FROM products WHERE Non_unique = 0;

On PostgreSQL the equivalent is \d products in psql, which prints every index and its definition. Registration is implicit: you do not declare anything to Eloquent, because the guarantee comes from the schema.

Two behaviours surprise people. First, an eloquent upsert does not fire model events: no creating, no saved, no observers, no timestamps unless you set them. Second, on MySQL an ON DUPLICATE KEY UPDATE consumes auto-increment values for conflicting rows, so a table with a small integer primary key can exhaust its range faster than the row count suggests. PostgreSQL sequences behave the same way. Plan for it on wide imports.

Product::upsert(
    $rows,                              // array of arrays
    ['sku'],                            // conflict target
    ['price', 'stock', 'updated_at']    // columns to overwrite
);

Chunk eloquent upsert calls into batches of 1,000 to 5,000 rows. A single statement with 50,000 rows can exceed max_allowed_packet on MySQL or hit PostgreSQL parse limits, and the failure arrives as a generic query exception that does not name the row.

Why updateOrCreate breaks under concurrency

Eloquent updateOrCreate runs a SELECT, then either an UPDATE or an INSERT. Those are separate round trips with no lock held between them, so two processes can both find nothing, both insert, and one hits the unique index. It is a read-modify-write race, and the smaller the gap between requests, the more often it fires.

You can see the window directly in the generated SQL:

select * from products where sku = 'A-100' limit 1;
insert into products (sku, price) values ('A-100', 19.99);

Between those two statements any other connection can run the same pair. On MySQL with the default REPEATABLE READ isolation, the SELECT is a non-locking consistent read, so it cannot even see an uncommitted insert from a competing transaction; the conflict only surfaces when the second statement tries to write. On PostgreSQL the same non-locking read gives identical behaviour.

The observable failure is a QueryException with SQLSTATE 23000 on MySQL or 23505 on PostgreSQL, thrown from the insert. Retrying the whole call helps, but only if the retry re-reads the row, which is why a naive retry(3, ...) around updateOrCreate sometimes appears to work and sometimes does not.

updateOrCreate is safe when a single writer owns the table: a console command, a queue with one worker, a migration. It is not safe when two HTTP requests can touch the same natural key.

Comparing the three write primitives

PrimitiveStatements per callConcurrency safeFires model eventsReturns
updateOrCreate2 (select, then write)No, without a unique indexYesModel instance
eloquent upsert1Yes, given a unique indexNoAffected row count
insertOrIgnore1YesNoInserted row count

The count differences matter. On MySQL, an eloquent upsert returns 1 for an insert and 2 for an update, so you cannot use it to distinguish new rows. On PostgreSQL the number is a plain affected-row count. Neither tells you which rows were new, so if downstream logic depends on that, use a RETURNING clause through DB::select or split the batch by querying existing keys first.

Insert-or-ignore is not the same as upsert

insertOrIgnore silently drops conflicting rows, which is correct for idempotent ingestion where the first write wins. It is wrong when a later payload carries fresher data. The dangerous version of this bug is subtle: the import reports success, no exception is thrown, and the row keeps stale values because a retry of an older payload arrived first.

// First writer wins. Later payloads are discarded, not merged.
Product::insertOrIgnore($rows);

Choose an eloquent upsert when the newest payload should overwrite, and insert-or-ignore only when duplicates are guaranteed identical or intentionally frozen.

Deadlocks under concurrent upserts

Two transactions inserting the same set of keys in different orders can deadlock even with an eloquent upsert, because each statement acquires row locks as it walks the batch. MySQL reports error 1213; PostgreSQL reports 40P01. The fix is to sort each batch by the conflict key before writing so all writers take locks in the same order.

collect($rows)
    ->sortBy('sku')
    ->chunk(2000)
    ->each(fn ($chunk) => Product::upsert(
        $chunk->values()->all(),
        ['sku'],
        ['price', 'stock']
    ));

Keep transactions short around these batches. A long transaction holding thousands of row locks blocks unrelated readers on the same pages and turns one slow import into a site-wide stall. On PostgreSQL you can also watch waiting locks live with pg_locks joined against pg_stat_activity when you need to confirm the diagnosis.

Filling created_at and updated_at

Eloquent's automatic timestamps do not run inside an eloquent upsert. If your columns are NOT NULL without a default, the statement fails on insert. Set both keys yourself in the payload, or give the columns a database default such as CURRENT_TIMESTAMP. Setting them per row also lets you distinguish newly created records later, since the overwrite list controls which columns change on conflict:

$now = now();
$rows = array_map(fn ($r) => $r + [
    'created_at' => $now,
    'updated_at' => $now,
], $rows);

Note that including created_at in the overwrite list would reset it on every conflict, which is almost never what you want.

What to measure before and after

Wrap the change in a query-count assertion so a regression shows up in CI rather than in production. If you use DB::listen or Laravel's query log, an eloquent upsert import should log roughly one statement per chunk instead of two per row. A 10,000-row updateOrCreate loop logs at least 10,000 statements, often 20,000 if every row is a miss retried once.

The same query-count discipline applies to reads; see fixing Eloquent N+1 in Laravel for the read-side version of this problem. For background jobs that drive imports, pair the write primitive with the retry semantics described in Laravel queue best practices, since an upsert that is safe on its own is still vulnerable to a job that runs twice without an idempotency key.

Verify the behaviour on your own schema rather than trusting a general rule: run two PHP processes against the same key set, with the unique index in place, and check the row count and column values afterwards. The two references that describe the generated SQL across versions are the Laravel upsert documentation and the PostgreSQL INSERT page, which documents the ON CONFLICT clauses your statement compiles to.

Frequently asked questions

Does an Eloquent upsert fire model events?

No. It bypasses the model lifecycle entirely, including observers, mutators, and automatic timestamps. If downstream logic depends on created or saved events, dispatch an explicit event after the batch commits, or fall back to updateOrCreate when concurrency is not a concern.

Can I use an Eloquent upsert without a unique index?

No. PostgreSQL requires a unique or primary key index matching the conflict target and rejects the statement otherwise. MySQL relies on any unique index but behaves unpredictably without one, potentially updating unintended rows. Add the index before switching the write path.

How many rows should one upsert call handle?

Chunk between 1,000 and 5,000 rows. Larger statements risk packet-size limits and produce errors that do not identify the offending row, while very small chunks multiply round trips. Sorting within each chunk by conflict key also reduces deadlock frequency between concurrent writers.

DD

David Dew Mallick

Software Engineer

I build AI-driven SaaS infrastructure and backend systems with Laravel, AWS, and SQL, and write about the engineering decisions behind them.

GitHubLinkedInEmail

More posts

  • Oct 7, 2026 · 8 min read

    PHP Type Juggling: Pitfalls and Fixes

    How PHP's loose comparison rules cause real bugs, which behaviors changed in PHP 8, and when to use strict types and strict comparisons to avoid them.

  • Oct 6, 2026 · 8 min read

    Laravel Queue Retry and Backoff Explained

    How Laravel queue retries, backoff, and timeouts actually work, why jobs silently double-run, and how to configure them for idempotent workers.

On this page

  • What does an Eloquent upsert actually do?
  • Why updateOrCreate breaks under concurrency
  • Comparing the three write primitives
  • Insert-or-ignore is not the same as upsert
  • Deadlocks under concurrent upserts
  • Filling created_at and updated_at
  • What to measure before and after
  • Frequently asked questions
  • Does an Eloquent upsert fire model events?
  • Can I use an Eloquent upsert without a unique index?
  • How many rows should one upsert call handle?
Back to top

End of record

Back to top↑

David Dew Mallick

Dhaka, Bangladesh

Contact

  • david.dew.mallick@g.bracu.ac.bd
  • GitHub
  • LinkedIn

2026 David Dew Mallick

  • Privacy
  • Terms
  • Cookies