skip to content

questions

5

When you INSERT a row into a child table that has a foreign key referencing a parent table, what extra work does the database do before that statement can succeed, and when does that extra work become a throughput bottleneck?

level: juniorimportance: must knowfreq 55%

answer

  1. Probe parent unique index per row
  2. Key-share lock held to commit
  3. Hot parent row = queue of writers
  4. Unindexed child FK = full scan on parent delete
  5. Six FKs = six probes per insert

basics

~20 s

For every inserted row the engine looks up the referenced key in the parent's unique index to prove the parent exists, and takes a light lock on that parent row so it cannot be deleted or re-keyed before commit. It costs an extra index probe plus contention on hot parent rows.

solid answer

~60 s

A foreign key is not free metadata — it is a per-row check executed inside the writing transaction. On each child INSERT (or an UPDATE that changes the FK columns) the engine: 1. Probes the parent's primary/unique index for the referenced value — an extra index descent, and often a row visit to confirm the parent version is visible. 2. Takes a **share-style lock** on that parent row (a key-share lock, or in some engines a shared row lock) so no concurrent transaction can delete it or change its key until the child transaction ends. So the cost is roughly one extra point lookup per FK per row, plus a lock entry held until commit. It bites when: many child rows point at the *same* parent row (a status or tenant lookup row) — every writer queues on the same parent lock; when a table has several FKs, multiplying probes; when the parent index is cold, making the probe random I/O; and when transactions touch parents in different orders, producing deadlocks.

code

sql · 6 lines
sql
-- conceptually executed per inserted child row:
SELECT 1 FROM orders WHERE id = :order_id FOR KEY SHARE;

-- almost never created automatically -- without it,
-- DELETE FROM orders WHERE id = ? scans order_line
CREATE INDEX ix_order_line_order_id ON order_line (order_id);

go deeper

for a junior

Know the shape: one lookup in the parent per inserted row to prove the parent exists, and it happens on every insert, not in the background.

for a middle

Add the locking detail — a share-style lock on the parent row held to commit — and that an update skips the check when the FK columns are unchanged.

for a senior

Diagnose it: hot parent rows serializing writers, unindexed child FK columns turning parent deletes into scans, and deadlocks arising from parent-lock ordering.

for a principal

Frame the tradeoff — where the integrity budget is spent, when to keep constraints under high write rates versus batch-validate, and how FK topology (fan-in to hot lookup rows) shapes write scalability.

## What a foreign key actually is at write time A foreign key constraint says: every non-NULL value in the child's referencing columns must match an existing row in the parent's referenced columns. The referenced columns must be a primary key or a unique key — the SQL standard requires it, and engines rely on it, because the check has to be a *point lookup* against a unique structure, not a scan. Enforcement is not a background job. It runs synchronously, per row, inside the transaction that writes the child row, and it must hold until commit — otherwise a concurrent transaction could delete the parent between your check and your commit and leave an orphan. ## The insert path, step by step For `INSERT INTO order_line(order_id, ...) VALUES (42, ...)`: 1. The child row is written to the table and to each of the child's own indexes (normal write cost, nothing to do with the FK). 2. The engine executes an internal existence check equivalent to `SELECT 1 FROM orders WHERE id = 42 FOR KEY SHARE`. That is a descent of the parent's unique index (root → branch → leaf), and usually a fetch of the parent row itself to check visibility under MVCC. 3. It records a **shared / key-share lock** on that parent row. This lock blocks anyone who wants to delete the parent or change its key, but it does *not* block other inserters of sibling children, and in most modern engines it does not block ordinary updates of the parent's non-key columns. 4. The lock is held until the child transaction commits or rolls back. An UPDATE that does not touch the FK columns generally skips the check entirely — engines compare old and new key values first. ## Where the cost lands **Extra I/O.** One index descent plus a row visit per FK per row. If the parent table and its index are small and hot in the buffer pool (a 30-row `status` table), the probe is essentially free. If the parent is a billion-row table and the reference is random, the probe is a cache miss — and that miss is paid on the write path, where you were expecting only sequential log writes. **Lock contention.** This is the real bottleneck. Locks are on the *parent row*. If every child row references the same parent — a tenant row, a `currency` row, a `status = 'NEW'` row — then every concurrent writer touches that same lock entry. Share locks are compatible with each other, so simple inserts still proceed; the pain shows up when something wants an exclusive lock on that hot parent (an update to its key, a delete, or in older engine versions any update at all), at which point the whole write stream stalls behind it. **Deadlocks.** Two transactions inserting children of parents A and B in opposite orders can deadlock on the parent locks even though they never touch the same child row. This surprises people because their SQL never mentions the parent table. **Multiplication.** A table with six foreign keys pays six probes and takes six locks per inserted row. A bulk insert of a million rows takes six million probes. ## The other direction: parent deletes and updates The check runs both ways. Deleting a parent row (or updating its key) forces the engine to prove no child references it. That is a lookup on the *child* side, and it needs an index on the child's FK columns. Most engines do not create that index for you (a notable exception is one that indexes FK columns automatically). Without it, every parent delete becomes a full scan of every referencing child table — the single most common FK performance bug in the wild. Symptom: deleting one row from a small table takes seconds and gets slower as the child grows. ## Mitigations that keep the constraint - **Index the child's FK columns.** Non-negotiable if parents are ever deleted or re-keyed. - **Keep parent rows narrow and cached** so the probe is a buffer hit. - **Avoid pointing millions of children at one hot parent row** where you can; or accept share-lock traffic and never take exclusive locks on that row during peak load. - **Touch parents in a consistent order** across code paths to avoid deadlocks. - **Batch**, so the per-statement overhead amortizes; the per-row check remains but the round trips and log flushes do not. - **Defer the check to commit** where the engine supports deferrable constraints, if the problem is ordering within a transaction rather than raw cost — deferral moves the work, it does not remove it, and it costs memory to remember pending checks. - **Drop and re-validate around bulk loads**, where a single set-based anti-join replaces N point lookups. ## What to say in an interview "One extra unique-index probe per row plus a shared lock on the parent row, held to commit. It hurts on hot parent rows and when the child's FK column is unindexed, because then parent deletes scan the child." That sentence covers both directions and the two failure modes interviewers are fishing for.

  • Does the engine create an index on the child's foreign key column for you?
    Almost never — most engines index only the parent's referenced key, which must already be a primary or unique key. The child side is your responsibility. Without it, every parent delete or key update triggers a full scan of the child table to prove no rows reference it, so the delete cost grows with the child's size.
  • Two transactions insert unrelated child rows and deadlock. How is that possible when they never touch the same row?
    Both take shared locks on parent rows as part of FK enforcement. If transaction A locks parent 1 then parent 2, and B locks parent 2 then parent 1, they deadlock on the parent locks even though their child rows are disjoint. The fix is to touch parents in a consistent order, or to reduce transaction scope so fewer parents are held at once.
  • Does an UPDATE on the child always re-run the foreign key check?
    No. Engines compare the old and new values of the referencing columns and skip the check when they are unchanged, which is the common case. Only an update that actually changes the FK columns pays for a fresh parent probe and lock.

It is like a bouncer checking a guest list entry and then keeping a finger on that name so nobody erases it while your guest is inside — cheap per guest, but everyone pointing at the same name crowds around one line of the list.

saying these in an interview costs you the question

  • Saying foreign keys are 'just metadata' or 'checked by the optimizer', with no per-row runtime cost
  • Assuming the database automatically indexes the child's foreign key column
  • Claiming FK enforcement takes an exclusive lock on the parent row and therefore serializes all inserts
  • Believing the check only runs on the child side, so parent deletes are free
  • Saying deferring the constraint to commit removes the work rather than moving it

context

open as a page

You must load 200 million rows into a table that already has several secondary indexes, foreign keys and CHECK constraints. How would you sequence the load so it finishes in hours rather than days, and what has to be true at the end before the data can be trusted?

level: seniorimportance: must knowfreq 45%

basics

~20 s

Load into an empty table or a staging partition with indexes and foreign keys removed, use the bulk-load API in large batches, then rebuild indexes with sort-based builds and re-add constraints so they are validated by one set-based scan each. Afterwards verify every constraint is in a validated, not merely enabled, state.

open as a page

Rank the per-row write cost of a CHECK constraint, a UNIQUE constraint, and a FOREIGN KEY constraint on the same table, and explain what makes them differ by orders of magnitude.

level: middleimportance: should knowfreq 32%

basics

~20 s

CHECK is cheapest: pure CPU on the row's own column values, no extra pages, no locks. UNIQUE costs an index probe plus an index write. FOREIGN KEY costs a probe into another table plus a lock on the parent row held to commit, so it is the only one that makes writers contend.

open as a page

Compared with a plain non-unique index on the same column, what extra work does a unique index perform on every INSERT, and why can engines not defer or buffer that work the way they can for non-unique index maintenance?

level: middleimportance: should knowfreq 45%

basics

~20 s

Both write a leaf entry. The unique one must first read the key's position and prove no live duplicate exists, and if another uncommitted transaction inserted the same key it must wait for that transaction to finish. That read and that wait cannot be deferred.

open as a page

A DELETE that removes a single row from a parent table occasionally runs for minutes and blocks other sessions. Explain how deleting one row can generate a very large amount of write work, and how you would bound the blast radius.

level: seniorimportance: should knowfreq 35%

basics

~20 s

Cascading referential actions multiply: one parent row deletes all its children, each child deletes its own children, and every deleted row costs table and index maintenance, logging and a lock held to commit. Bound it by deleting in batches yourself, archiving instead of deleting, or dropping whole partitions.

open as a page