Foreign Key Trigger Cost in Deletes #
Deleting 5,000 old rows from customers takes 38 minutes. The DELETE’s own plan is a 4 ms index scan. The time is in a line below the plan: Trigger for constraint invoices_customer_id_fkey on customers: time=2280412.6 calls=5000. For each deleted customer, PostgreSQL checked invoices for referencing rows — with a sequential scan, because nobody indexed invoices.customer_id.
Write plans in general are covered in data modification query plans. This page covers the most common hidden cost in them.
The Planner Condition #
A foreign key invoices.customer_id REFERENCES customers(id) is enforced by internal triggers on both tables:
- On
invoices(the referencing table), inserts and key updates check that the referenced customer exists. That check uses the primary key index oncustomers.id, which always exists — so it is cheap. - On
customers(the referenced table), deletes and key updates check whether anyinvoicesrow still references the old key, and apply theON DELETEaction (NO ACTION,RESTRICT,CASCADE,SET NULL,SET DEFAULT). That check runs a query equivalent toSELECT 1 FROM invoices WHERE customer_id = $1 FOR KEY SHARE— or, forCASCADE,DELETE FROM invoices WHERE customer_id = $1— once per deleted parent row.
PostgreSQL creates an index on the referenced side automatically (the key must be unique) but never on the referencing column. Without an index on invoices.customer_id, each check is a sequential scan of invoices. The cost is deleted parent rows × referencing table scan, for every referencing foreign key, and cascades repeat the pattern at every level.
These checks run as AFTER triggers. They do not appear in the plan tree; EXPLAIN ANALYZE reports them after the plan, one line per constraint, with total time and number of calls. Their queries are planned internally and are not shown.
Annotated EXPLAIN Evidence #
BEGIN;
EXPLAIN (ANALYZE, BUFFERS)
DELETE FROM customers WHERE closed_at < '2020-01-01';
ROLLBACK;
Delete on customers (actual time=4.2..4.2 rows=0 loops=1)
Buffers: shared hit=15210
-> Index Scan using customers_closed_at_idx on customers (actual time=0.03..3.1 rows=5000 loops=1)
Index Cond: (closed_at < '2020-01-01'::date)
Planning Time: 0.4 ms
Trigger for constraint invoices_customer_id_fkey on customers: time=2280412.6 calls=5000
Trigger for constraint sessions_customer_id_fkey on customers: time=1204.3 calls=5000
Trigger for constraint addresses_customer_id_fkey on customers: time=48.2 calls=5000
Execution Time: 2281670.4 ms
-- plan tree: 4 ms. Triggers: 38 minutes.
-- invoices check: 456 ms per call → a sequential scan of invoices per deleted customer
-- addresses check: 0.01 ms per call → an index exists on addresses.customer_id
After indexing the referencing column:
CREATE INDEX CONCURRENTLY invoices_customer_id_idx ON invoices (customer_id);
Trigger for constraint invoices_customer_id_fkey on customers: time=62.1 calls=5000
Trigger for constraint sessions_customer_id_fkey on customers: time=1204.3 calls=5000
Execution Time: 1318.9 ms
Step-by-Step Resolution #
-
Run the delete under
EXPLAIN ANALYZEin a rolled-back transaction and read everyTrigger for constraintline. Dividetimebycalls. -
List foreign keys without a supporting index on the referencing side:
SELECT c.conrelid::regclass AS referencing_table, c.conname, array_agg(a.attname ORDER BY k.ord) AS columns FROM pg_constraint c CROSS JOIN LATERAL unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord) JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum WHERE c.contype = 'f' AND NOT EXISTS ( SELECT 1 FROM pg_index i WHERE i.indrelid = c.conrelid AND (i.indkey::int2[])[0:cardinality(c.conkey) - 1] @> c.conkey ) GROUP BY c.conrelid, c.conname;The query treats an index as supporting when its leading columns cover the foreign key columns.
-
Create the missing indexes concurrently.
CREATE INDEX CONCURRENTLY invoices_customer_id_idx ON invoices (customer_id); -
Check cascades at every level. An
ON DELETE CASCADEfromcustomerstoinvoicestoinvoice_linesneeds indexes on bothinvoices.customer_idandinvoice_lines.invoice_id. -
Batch large parent deletes so the per-statement trigger queue stays small and locks are short.
-
Weigh index cost on write-heavy referencing tables. An index on a large, insert-heavy referencing table costs every insert; if parent rows are almost never deleted or keys never updated, skipping it can be a deliberate choice — document it.
Before and After #
-- BEFORE: invoices.customer_id not indexed
Delete on customers (4.2 ms) Trigger for constraint invoices_customer_id_fkey: time=2280412.6 calls=5000 Execution Time: 2281670.4 ms
-- AFTER: CREATE INDEX CONCURRENTLY invoices_customer_id_idx
Delete on customers (4.2 ms) Trigger for constraint invoices_customer_id_fkey: time=62.1 calls=5000 Execution Time: 1318.9 ms
The same cost in updates and truncation #
Updating a referenced key — UPDATE customers SET id = … — fires the same checks, which is one reason surrogate primary keys should never change. Updates to non-key columns do not fire them. TRUNCATE skips row-level triggers entirely, but refuses to truncate a referenced table unless the referencing tables are truncated in the same command or with CASCADE, so it is not a way around the checks for partial deletes.
The locking side matters too. The check on the referencing table takes FOR KEY SHARE locks on any referencing rows it finds, and concurrent inserts into invoices referencing a customer being deleted block until the delete commits or rolls back. A slow, unindexed check holds those locks longer, so the symptom on a busy system is often blocked inserts elsewhere rather than just a slow delete — the investigation approach is in lock waits that look like slow plans.
Monitoring for the problem before it happens #
Missing foreign key indexes are invisible until someone deletes parent rows, which in many systems happens rarely: a retention job, a GDPR erasure request, an account closure. The first time it runs against production volumes, it can lock referencing tables for hours. Running the catalog query above as part of schema review — or as a check in continuous integration against a migrated schema — catches new foreign keys without indexes when they are introduced, long before a delete exercises them. Treat every exception as an explicit, documented decision rather than an oversight.
Common Pitfalls #
Tuning the delete’s scan. The plan tree is usually trivial. Diagnostic signal: node time in milliseconds, execution time in minutes. Fix: read the trigger lines.
Assuming foreign keys create indexes. Only the referenced key is indexed. Diagnostic signal: referencing columns with no index. Fix: create them explicitly.
Composite foreign keys with partial index coverage. An index on only the first column may still leave a large range to filter. Diagnostic signal: moderate per-call times on composite keys. Fix: index all foreign key columns in order.
Cascades several levels deep. Each level repeats the pattern. Diagnostic signal: trigger lines for tables two levels down. Fix: index every referencing column in the chain.
Frequently Asked Questions #
Why is DELETE slow when the query plan is fast? #
Foreign key checks on tables that reference the deleted rows run as triggers after each row and are reported as Trigger for constraint lines below the plan. Without an index on the referencing column, each check scans the referencing table.
Does PostgreSQL automatically index foreign key columns? #
No. The referenced key must have a unique index, but the referencing column in the child table is not indexed automatically. You need to create that index yourself.
Where do foreign key checks appear in EXPLAIN ANALYZE? #
After the plan tree, as lines like Trigger for constraint name on table: time=… calls=…. Their time is included in Execution Time but not in any plan node.
Related #
- Data Modification Query Plans — parent guide: where write costs appear
- Covering Indexes for Foreign Key Joins — indexes on referencing columns for reads too
- Finding Unused Indexes Safely — FK support indexes look unused to scan counters