DEV Community

Иван Недомолков
Иван Недомолков

Posted on Originally published at tezbase.kz

Your UNIQUE constraint in PostgreSQL lets duplicates through. Here is how to find every one

The table has a UNIQUE constraint, and identical rows sit in it in pairs. If at least one column of the key is nullable, this is expected behaviour: following the SQL standard, PostgreSQL treats two NULLs as distinct, so a row with an empty field never matches any other row, not even its own copy. No error, nothing in the log, the constraint is in place.

We hit this in a service table next to a 1C:Enterprise database, caught by the final test of a migration. Below is the procedure we now run on every such table: find the keys, measure how many rows are actually protected, decide what the data means, fix it, and prove the fix. All queries were run on PostgreSQL 17.10, MS SQL Server behaviour was checked on 2022.

Reproduce it in a minute

CREATE TABLE integration_event (
  id        bigserial PRIMARY KEY,
  entity_id uuid NOT NULL,
  state     text NOT NULL,
  kind      text,                       -- empty on early states
  UNIQUE (entity_id, state, kind)
);

INSERT INTO integration_event (entity_id, state, kind)
VALUES ('11111111-1111-1111-1111-111111111111', 'CREATED', NULL);
INSERT INTO integration_event (entity_id, state, kind)
VALUES ('11111111-1111-1111-1111-111111111111', 'CREATED', NULL);

SELECT count(*) FROM integration_event;  -- 2
Enter fullscreen mode Exit fullscreen mode

Both inserts succeed. The same key on SQL Server behaves the opposite way:

CREATE TABLE #t (a int NOT NULL, b int NULL, UNIQUE (a, b));
INSERT INTO #t (a, b) VALUES (1, NULL);
INSERT INTO #t (a, b) VALUES (1, NULL);  -- error 2627, UNIQUE KEY violation
Enter fullscreen mode Exit fullscreen mode
DBMS Two rows (1, NULL) under UNIQUE (a, b)
PostgreSQL before 15 both are stored, no way to switch it
PostgreSQL 15+ both are stored unless NULLS NOT DISTINCT is declared
MS SQL Server the second one fails with error 2627

This matters most if you are migrating. A key that held off duplicates on SQL Server for years gets created on PostgreSQL without a single warning and silently stops protecting rows with an empty field. The migration report is clean, the schema is the same.

Step 1. Find every such key with one query

The symptom is simple: a unique index with a nullable column in its key. Search pg_index, not pg_constraint. A UNIQUE constraint is always backed by an index, so pg_index shows both declared constraints and indexes created directly with CREATE UNIQUE INDEX.

PostgreSQL 15 and later:

SELECT n.nspname                                   AS schema_name,
       c.relname                                   AS table_name,
       i.relname                                   AS index_name,
       coalesce(con.conname, '-')                  AS constraint_name,
       string_agg(a.attname, ', ' ORDER BY k.pos)  AS nullable_key_columns,
       pg_get_expr(ix.indpred, ix.indrelid)        AS index_predicate
FROM pg_index ix
JOIN pg_class      i ON i.oid = ix.indexrelid
JOIN pg_class      c ON c.oid = ix.indrelid
JOIN pg_namespace  n ON n.oid = c.relnamespace
CROSS JOIN LATERAL unnest(ix.indkey::int2[]) WITH ORDINALITY AS k(attnum, pos)
JOIN pg_attribute  a ON a.attrelid = ix.indrelid AND a.attnum = k.attnum
LEFT JOIN pg_constraint con ON con.conindid = ix.indexrelid AND con.contype = 'u'
WHERE ix.indisunique
  AND NOT ix.indisprimary
  AND NOT ix.indnullsnotdistinct        -- already switched, no hole
  AND k.pos <= ix.indnkeyatts           -- INCLUDE columns are not part of the key
  AND NOT a.attnotnull
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
  AND n.nspname NOT LIKE 'pg_toast%'
GROUP BY n.nspname, c.relname, i.relname, con.conname, ix.indpred, ix.indrelid
ORDER BY 1, 2, 3;
Enter fullscreen mode Exit fullscreen mode

On PostgreSQL 11-14 the indnullsnotdistinct column does not exist yet, so drop that line. Everything else works: indnkeyatts arrived in 11 together with INCLUDE. The query only reads the catalog and needs no superuser rights.

Why not two simple queries

Our first version was two queries: one over pg_constraint with contype = 'u', one over pg_index with partial indexes filtered out. On a test schema of seven tables it went wrong in five different ways, and you will meet each of them:

What the query had What the run showed Fix
no pg_toast filter 88 extra rows about chunk_id and chunk_seq on an empty cluster nspname NOT LIKE 'pg_toast%'
attnum = ANY (indkey) takes every index column a column from INCLUDE (note) reported as a hole key position not greater than indnkeyatts
pg_constraint knows nothing about NULLS NOT DISTINCT an already fixed constraint listed indnullsnotdistinct flag from pg_index
partial indexes dropped entirely an index with WHERE active vanished, with the same hole predicate shown as a column, a human decides
constraint and its index in two queries one hole reported twice a single pass over pg_index

Two false positives remain. A generated column like coalesce(kind, '__NULL__') is formally nullable but never empty, so you can cross it off. An expression index such as (a, lower(b)) has a zero in indkey for the expression and the join loses it, so check those by eye with \d table_name.

Step 2. Measure the share of unprotected rows

The list does not tell you how bad it is. A key with two empty values per thousand rows can wait. A key that is empty in most rows protects almost nothing.

SELECT count(*)                                                AS total,
       count(*) FILTER (WHERE kind IS NULL)                    AS unprotected,
       round(100.0 * count(*) FILTER (WHERE kind IS NULL)
             / nullif(count(*), 0), 1)                         AS unprotected_pct
FROM integration_event;
Enter fullscreen mode Exit fullscreen mode

With several nullable key columns, write kind IS NULL OR other_column IS NULL in the FILTER: one empty field is enough for a row to fall outside the check.

Our case for scale: a service table of external operation history, a three-column key where the third column is filled only on later states. Over 66 days it received about 2,200 events, and the third column was empty in 79 % of rows. About 460 rows were protected, about 1,740 were not. It was caught by the final migration test, which replayed the same events and expected zero new rows. Your share will differ, so copy the query, not the number.

Accumulated duplicates are found by grouping on the same key:

SELECT entity_id, state, kind, count(*) AS row_count
FROM integration_event
GROUP BY entity_id, state, kind
HAVING count(*) > 1;
Enter fullscreen mode Exit fullscreen mode

Note the inconsistency that keeps this bug alive for years: GROUP BY puts all NULLs into one group, the unique index treats them as different. Same database, two answers to "are there duplicates". The grouping is the honest one.

Step 3. Decide whether it is one entity or two

Before fixing, answer a question about the data: do two rows with the same key and an empty field describe the same thing?

For an integration state table the answer is yes: a replayed event after a handler restart is the same record and must be rejected. Same for exchange buffers and job queues, where handlers must be safe to rerun.

Sometimes it is the other way round. In reference and archive data an empty field may mean "unknown" in one row and "not applicable" in another, and those rows really are different. Gluing them together with a uniqueness rule corrupts data silently. Put an explicit value instead of the empty one first, then add the constraint if you still need it.

Step 4. Fix it, depending on the version

PostgreSQL 15 and later

Remove what has already piled up, otherwise the new index fails with could not create unique index ... Key (...)=(..., null) is duplicated. This keeps the row with the smallest id in each group and compares empty values with IS NOT DISTINCT FROM:

DELETE FROM integration_event e
USING integration_event d
WHERE e.entity_id = d.entity_id
  AND e.state     = d.state
  AND e.kind IS NOT DISTINCT FROM d.kind
  AND e.id > d.id;
Enter fullscreen mode Exit fullscreen mode

Which row to keep is a business decision, not this query's. If duplicates differ in date or in the external system's response, look at them with the Step 2 query first.

Then build the new index without blocking writes and swap the constraint:

CREATE UNIQUE INDEX CONCURRENTLY integration_event_uq_new
  ON integration_event (entity_id, state, kind) NULLS NOT DISTINCT;

ALTER TABLE integration_event
  DROP CONSTRAINT integration_event_entity_id_state_kind_key,
  ADD CONSTRAINT integration_event_uq UNIQUE USING INDEX integration_event_uq_new;
Enter fullscreen mode Exit fullscreen mode

USING INDEX turns the ready index into a constraint and renames it (PostgreSQL prints a NOTICE about it). The NULLS NOT DISTINCT flag survives: \d integration_event shows UNIQUE CONSTRAINT ... NULLS NOT DISTINCT. For a new table write it in the declaration: UNIQUE NULLS NOT DISTINCT (entity_id, state, kind).

CREATE INDEX CONCURRENTLY cannot run inside a transaction. If it fails, an invalid index is left behind: drop it and build again.

PostgreSQL 11-14

There is no switch, so you have two workarounds, each with a cost.

Two partial indexes, one for the filled field and one for the empty one:

CREATE UNIQUE INDEX integration_event_uq_kind
  ON integration_event (entity_id, state, kind)
  WHERE kind IS NOT NULL;

CREATE UNIQUE INDEX integration_event_uq_no_kind
  ON integration_event (entity_id, state)
  WHERE kind IS NULL;
Enter fullscreen mode Exit fullscreen mode

The common mistake is creating only the first one. It protects exactly the rows that were already protected. The other cost shows up with several nullable columns: every combination of empty fields needs its own index, two columns give four indexes, three give eight.

A generated column with a sentinel value:

ALTER TABLE integration_event
  ADD COLUMN kind_key text
  GENERATED ALWAYS AS (coalesce(kind, '__NULL__')) STORED;

CREATE UNIQUE INDEX ON integration_event (entity_id, state, kind_key);
Enter fullscreen mode Exit fullscreen mode

Checked: the second insert now fails on Key (a, kind_key)=(1, __NULL__) already exists. The risk is that the sentinel shares the domain with real values. If the external system ever sends the literal text __NULL__, two different rows become equal. Pick a value that cannot physically appear in the input; for numbers, a value outside the allowed range pinned with CHECK. GENERATED ... STORED needs PostgreSQL 12, so on 11 only partial indexes remain.

Step 5. Prove the protection works

"The rerun finished without errors" proves nothing here: duplicates are written silently both before and after the fix. Count new rows, and the count must be zero.

The shortest way without a handler is to insert the table into itself inside a transaction and roll back:

BEGIN;
CREATE TEMP TABLE before_replay ON COMMIT DROP AS
  SELECT count(*) AS n FROM integration_event;

INSERT INTO integration_event (entity_id, state, kind)
SELECT entity_id, state, kind FROM integration_event
ON CONFLICT DO NOTHING;

SELECT (SELECT count(*) FROM integration_event)
     - (SELECT n FROM before_replay) AS new_rows;
ROLLBACK;
Enter fullscreen mode Exit fullscreen mode

Every row the constraint lets through lands a second time, and new_rows shows how many. On a 100-row test table where the field was filled in every fifth row, we got 80 before the fix and 0 after. The id is left out of the select, it comes from the sequence; the sequence does not roll back, so expect a gap. Insert triggers will fire, so run this on a copy.

The copy should be fresh. The share of empty values on a test database restored half a year ago says nothing about production today, and synthetic data gives zero percent because whoever wrote the test filled every field.

If you have the handler, it is even simpler: run it a second time on the same data and compare row counts. In a migration runbook this is one line, "a repeated run creates zero new records", and it catches this whole class of bugs.

Where it shows up

Platform tables of an ERP or accounting system are usually not yours to touch. Everything built around them is: staging tables for data exchange, integration buffers, web service call logs, background job queues. Uniqueness there is held only by the schema, and that is exactly where it most often sits on a key with an optional field.

The most common road to this hole is a migration from SQL Server, where the same key used to work, so Step 1 deserves its own line in a migration checklist. Other things that break on that road are covered in our article on moving 1C databases to PostgreSQL (in Russian). The original, longer write-up with a section on 1C:Enterprise external data sources is on our site (in Russian).

Run the Step 1 query on your database and tell me in the comments how many unique keys with nullable columns it found, and what share of empty values the worst one had. I am curious whether 79 % is an outlier or the normal picture for tables around a business system.

Translated and adapted from our Russian article with AI assistance; queries and numbers checked against the original.

Top comments (0)