Why Your Batch Import Lost Rows When a Partial Unique Index Rejected Just One

Date:

The import finished, the log printed a success line, and most of the rows are not in the table. If that table has a partial unique index, one row probably took the whole batch down with it.

The short answer: A batch import that writes all its rows in one transaction is all-or-nothing. If the table has a partial unique index (uniqueness only over rows matching a WHERE clause) and your duplicate check before each insert tests a narrower condition than the index does, some rows slip past the check and hit the index. One unique_violation aborts the transaction, so every other valid row in the batch rolls back too, and a tool that prints the count it meant to write will still report success. Fix it by writing the rows with INSERT … ON CONFLICT … DO UPDATE (an UPSERT) whose conflict target repeats the index’s predicate exactly. Then add one check that catches the whole family of bugs: after each batch, confirm rows_in == rows_committed + rows_skipped.

The symptom

You run an import. It reads a spreadsheet of contacts and writes them to a staging table, in one transaction, because a half-applied import is harder to clean up than none at all. The run ends without an error, or with a summary that looks like partial success. Then you count the rows and most of them are missing.

Nothing in the log points at the missing rows. The only error, if you can find it, names a row that looks perfectly fine. That mismatch is the clue.

Step 1: check whether the table has a partial unique index

A partial unique index enforces uniqueness only over the rows that match a predicate. A contact staging table might use one like this:

CREATE UNIQUE INDEX one_pending_per_email
  ON contact_import (list_id, email)
  WHERE status = 'pending';
Code language: SQL (Structured Query Language) (sql)

In words: at most one pending row per email on each mailing list. Rows that were already approved or rejected don’t count, so the import history can grow freely while only work in progress has to be unique. That is a sensible design, and it is also where the trap starts.

In PostgreSQL you can list a table’s indexes and their predicates with \d contact_import in psql, or by querying pg_indexes. Look for UNIQUE together with WHERE.

Step 2: compare your duplicate check with the index

Most importers skip rows that are already present before they insert. Suppose yours was written for the common case of re-running the same file:

Skip the row if a pending row for this email already exists with the same phone number.

Read the two conditions side by side. The check skips a pending row with the same phone. Now someone fixes a phone number in the spreadsheet and re-imports it. The edited row has a pending twin with a different phone, so it passes the check, reaches a plain INSERT, and trips the index: unique_violation.

The check guards “same email, same phone”. The index forbids “same email, any phone”. The rows in between are the ones that blow up.

Step 3: understand why one error empties the batch

On its own, a single rejected row would be a small problem. Inside an all-or-nothing transaction it is a big one. A unique_violation on one row aborts the transaction, and every other row written in it rolls back, however valid it was.

In one case we diagnosed, a nine-row batch kept a single row. One row had a pending twin with different details; its insert raised; the transaction unwound; eight good rows were gone. The summary still read APPLIED: confirm=1, because the code around the insert reported the count it intended to write, not the count the database kept. Nobody noticed for a while, since the log said it worked.

Three things keep this hidden:

  • The row that raises is not the row you lose. The conflicting row is often the newer, corrected record. The casualties are unrelated rows that shared its transaction, so reading the error sends you to the wrong place.
  • The summary lies by omission. Later steps run on whatever was committed and report their own neat counts. Taken together, the output reads like success.
  • A partial index invites the wrong mental model. “An approved row can’t conflict” is true, and it makes the space of possible conflicts look smaller than it is.

The row that raises is usually fine; the rows you lose are the bystanders in its transaction.

Step 4: replace the plain INSERT with an UPSERT on the same predicate

Stop issuing an INSERT that can only raise. Use INSERT … ON CONFLICT … DO UPDATE so the clash is resolved in place, and give it the same predicate as the partial index:

INSERT INTO contact_import (list_id, email, phone, status)
VALUES (:list_id, :email, :phone, 'pending')
ON CONFLICT (list_id, email) WHERE status = 'pending'
DO UPDATE SET phone = EXCLUDED.phone, updated_at = now()
RETURNING (xmax = 0) AS was_insert;
Code language: SQL (Structured Query Language) (sql)

Two details matter here:

  • WHERE status = 'pending' on the conflict target has to match the index’s predicate. Without it, PostgreSQL cannot pick that index to arbitrate the conflict and the statement fails.
  • RETURNING (xmax = 0) is true for a fresh insert and false for an update. Count both, so the import can report “8 inserted, 1 updated” instead of hiding the update.

Before you adopt this, decide whether overwriting is acceptable. It works here because the staging table holds proposals waiting for review, and a newer proposal should replace an older one. If each colliding row were a real record in its own right (two genuine customers who happen to share a key), DO UPDATE would quietly destroy one of them. In that case, change the key so the rows don’t collide, or send the conflict to a person. It is the same question as failing before an irreversible side effect: decide what must not be destroyed before you write.

Step 5: reconcile the counts after every batch

The UPSERT fixes this particular bug. A counting rule catches the whole class:

After any batch write, assert rows_in == rows_committed + rows_intentionally_skipped.

The database knows exactly how many rows it kept. Compare that with the number you read from the file, and a silent rollback turns into a loud mismatch. If the counts don’t add up, fail the run rather than print a summary. Verifying the end state against the input, instead of trusting the writer’s own success line, is good practice for any write that moves data between systems, and it pairs well with a sane setup for database connection limits and concurrency.

Checklist

  1. Does the target table have a unique index with a WHERE clause?
  2. Does your skip-if-present check test exactly what that index enforces, no more and no less?
  3. Is the batch written in one transaction, so a single error rolls back everything?
  4. Do the writes use ON CONFLICT (…) WHERE <index predicate> instead of a plain INSERT?
  5. Is overwriting a clashing row actually correct for this table?
  6. Does the import compare rows read with rows committed plus rows skipped, and fail on a mismatch?

Frequently Asked Questions

Why does ON CONFLICT fail with “no unique or exclusion constraint matching” on a partial index?

Because the conflict target has to repeat the index’s predicate. For a partial unique index, ON CONFLICT (list_id, email) on its own matches no index, so PostgreSQL raises an error instead of resolving the clash. Write ON CONFLICT (list_id, email) WHERE status = 'pending', with the same columns and the same WHERE clause as the index, and PostgreSQL can use it to arbitrate.

Can I use ON CONFLICT DO NOTHING instead of DO UPDATE?

Yes, and it stops the rollback just as well: the clashing row is skipped and the rest of the batch commits. The cost is that the newer data in that row is thrown away without a word. In the phone-number example, the corrected number never lands. Use DO NOTHING only when the existing row should win, and count the skipped rows so the reconciliation check still adds up.

Should I use savepoints instead, so one bad row doesn’t roll back the rest?

You can wrap each row in a savepoint and roll back only that row, but then the batch is no longer all-or-nothing, and you still need to report the rejected rows somewhere. If the conflict is one you can resolve automatically, the UPSERT is simpler. If it needs a person, collect the conflicts and fail the batch with a clear list.

Share post:

Subscribe

Popular

More like this
Related

Replace the WordPress Logo With Your Site Icon, Everywhere It Shows

Changing the login logo is the well-known fix. The WordPress mark also sits in the toolbar, the login popup, every tab title and the admin footer, and on a multisite network each site should show its own icon, not one shared logo.

How to Show a WordPress Shortcode Without Running It

You write a tutorial that mentions a shortcode in a code example, and the published page shows something else: a gallery, a block of other posts, or an empty gap where the example should be. WordPress ran the shortcode you were only trying to mention.

How to Customize the tagDiv Newspaper Theme When the Accent Color Won’t Change

You change the Newspaper theme's accent color in the Theme Panel, save, and the header, menu and footer are still the old red. Then you try to adjust the phone layout outside the Composer and find every setting stored as base64-encoded JSON. Both have a reliable fix, step by step.

How to Update Rank Math SEO Fields Through the REST API Without Exposing Your Focus Keywords

You send a meta description to a post's REST API endpoint, WordPress replies with HTTP 200, and the field in the Rank Math panel is still empty. One function call per field fixes the write. Done the obvious way, it also publishes every focus keyword you chose.