Adding Unique Constraints Using an Existing Index
A bug let two accounts register with the same email address, and the fix is to make the database refuse duplicates: ALTER TABLE accounts ADD CONSTRAINT accounts_email_key UNIQUE (email). On a 40-million-row table that statement builds a unique index while holding a lock that blocks every write, and if even one duplicate exists it fails at the end, after all that work. PostgreSQL lets you build the index first — concurrently, without blocking writes — and then promote it into a constraint with ADD CONSTRAINT ... UNIQUE USING INDEX, a metadata-only step. The same approach turns an existing index into a primary key. This guide covers deduplication, the concurrent build and its failure mode, the attach step, and the MySQL equivalent. It belongs to Adding Constraints Without Downtime.
Symptom / Error Signatures
The blocking form shows as sessions waiting on Lock behind ALTER TABLE accounts ADD CONSTRAINT accounts_email_key UNIQUE (email) during a migration. The duplicate problem appears as:
ERROR: could not create unique index "accounts_email_key"
DETAIL: Key (email)=(ana@example.com) is duplicated.
With a concurrent build, that same failure leaves an index behind that is marked INVALID — ignored by queries but still enforced for new writes, so inserts of new duplicates start failing with 23505 even though the constraint does not officially exist. And ADD CONSTRAINT ... USING INDEX itself rejects unsuitable indexes: ERROR: index "accounts_email_idx" is not valid, or cannot create a constraint using a partial index / ... expression index.
Root Cause Analysis
A unique constraint in PostgreSQL is implemented by a unique B-tree index. ADD CONSTRAINT ... UNIQUE (col) builds that index in the plain, non-concurrent way — holding a SHARE lock that blocks writes — then records the constraint. CREATE UNIQUE INDEX CONCURRENTLY builds the same index while allowing writes, at the cost of a longer build and a messier failure mode. ADD CONSTRAINT name UNIQUE USING INDEX idx then adopts the finished index as the constraint’s backing index: it renames the index to the constraint name and records the constraint, taking ACCESS EXCLUSIVE only briefly. ADD CONSTRAINT ... PRIMARY KEY USING INDEX works the same way, provided the columns are also NOT NULL.
Not every unique index can be adopted. The index must be valid, a plain B-tree, non-partial, without expressions, and with default sort ordering. Partial or expression unique indexes (for example UNIQUE (lower(email))) are perfectly good at enforcing uniqueness — they just remain indexes rather than constraints, which matters only for features that require a real constraint, such as ON CONFLICT ON CONSTRAINT or being referenced by a foreign key.
| Goal | Online path | Resulting object |
|---|---|---|
| unique on plain columns | CREATE UNIQUE INDEX CONCURRENTLY, then USING INDEX |
constraint |
| case-insensitive uniqueness | CREATE UNIQUE INDEX CONCURRENTLY ... (lower(email)) |
unique index only |
| uniqueness among active rows | CREATE UNIQUE INDEX CONCURRENTLY ... WHERE deleted_at IS NULL |
unique index only |
| new primary key | unique index concurrently + NOT NULL via check, then PRIMARY KEY USING INDEX |
constraint |
Immediate Mitigation
1. Find duplicates before building anything. Run on a replica for large tables; resolve each group according to business rules (merge accounts, keep the newest, and so on).
-- PostgreSQL · read-only
SELECT email, count(*) AS copies, array_agg(id ORDER BY id) AS ids
FROM accounts
WHERE email IS NOT NULL
GROUP BY email
HAVING count(*) > 1
ORDER BY copies DESC
LIMIT 100;
2. Build the unique index concurrently.
-- PostgreSQL · must run outside a transaction · writes continue during the build
-- WARNING: if duplicates exist, the build fails at the end and leaves an INVALID index that still rejects new duplicates.
SET lock_timeout = '3s';
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS accounts_email_uidx ON accounts (email);
-- ROLLBACK PATH: DROP INDEX CONCURRENTLY IF EXISTS accounts_email_uidx;
3. If the build failed, drop the invalid index and try again. Check indisvalid, fix remaining duplicates (including any created during the build by writes the application made), and rebuild.
-- PostgreSQL · outside a transaction
SELECT indisvalid FROM pg_index WHERE indexrelid = 'accounts_email_uidx'::regclass;
DROP INDEX CONCURRENTLY IF EXISTS accounts_email_uidx;
4. Attach the index as a constraint.
-- PostgreSQL · migration role · brief ACCESS EXCLUSIVE; the index is renamed to the constraint name
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE accounts ADD CONSTRAINT accounts_email_key UNIQUE USING INDEX accounts_email_uidx;
COMMIT;
-- ROLLBACK PATH: ALTER TABLE accounts DROP CONSTRAINT accounts_email_key; (drops the index too)
MySQL 8.0 builds unique secondary indexes online. Deduplicate first, then state the online requirement explicitly so the statement fails instead of blocking if it cannot run online:
-- MySQL 8.0 · requires ALTER · online build; replicas replay it as one statement
-- WARNING: duplicate rows make the build fail with ERROR 1062 after scanning the table.
SET SESSION lock_wait_timeout = 5;
ALTER TABLE accounts ADD UNIQUE INDEX accounts_email_key (email), ALGORITHM=INPLACE, LOCK=NONE;
-- ROLLBACK PATH: ALTER TABLE accounts DROP INDEX accounts_email_key, ALGORITHM=INPLACE, LOCK=NONE;
Permanent Fix / Long-Term Pattern
Treat a new unique constraint as a data-cleaning project followed by a two-step schema change. The application must stop creating duplicates before the index build — typically by checking in code and handling 23505 gracefully — otherwise duplicates written during the build make it fail at the last moment. Then build concurrently, verify validity, and attach. Keep deduplication idempotent so it can be rerun right before the build.
When the business rule is really “unique among active rows” or “case-insensitive”, encode that exactly with a partial or expression unique index rather than forcing a plain constraint; the index is the enforcement, and it can be built online in the same way. For the concurrent build’s general mechanics and failure handling, see cleaning up invalid indexes after a failed build; for making the build step rerunnable in a migration tool, see making index creation idempotent across retries. Replacing a primary key — for example moving from int to bigint — uses the same attach technique at its final step, covered in widening int to bigint primary keys without downtime.
Verification Checklist
Frequently Asked Questions
Does ADD CONSTRAINT ... USING INDEX rebuild the index?
No. It adopts the existing index, renames it to the constraint name, and records the constraint. It takes a brief ACCESS EXCLUSIVE lock but does no scanning or building.
Can I use a partial unique index as a constraint?
No. USING INDEX requires a plain, non-partial, non-expression B-tree index. A partial unique index still enforces uniqueness among the rows it covers; it just remains an index rather than a named constraint.
What happens to writes that create duplicates during a concurrent unique build? The index is maintained during the build, so later phases detect the duplicate and the build fails at validation, leaving an invalid index. Make sure the application stops creating duplicates before you start.
Can a unique index replace a primary key online?
Yes: build a unique index concurrently on NOT NULL columns (use the check-constraint technique for NOT NULL), then use ADD CONSTRAINT ... PRIMARY KEY USING INDEX in the same transaction that drops the old primary key.