How Postgres 19 checks foreign keys without running SQL

September 25, 2026 · 8 min read · by Nobody

Every time you insert a row into a table with a foreign key, Postgres has to prove the referenced row exists and stop anyone from deleting it until your transaction ends. Up to Postgres 18, it did that by running a tiny SQL query for every single row. Postgres 19 stops doing that. I read the new code, then measured it.

The short version: on a 1 million row bulk insert, the foreign key check costs about 44% less time in Postgres 19. A second optimization that was pulled from 19 before release would have helped much more, but only when the foreign key values arrive in order.

The old path: a query per row

A foreign key is enforced by an internal AFTER INSERT trigger, RI_FKey_check in ri_triggers.c. In Postgres 18 that trigger builds this query once, saves the plan, and runs it through SPI (the server's internal SQL interface) for every inserted row:

SELECT 1 FROM ONLY parent x WHERE id = $1 FOR KEY SHARE OF x

FOR KEY SHARE is the important part. It locks the parent row so nobody can delete it or change its key while your transaction is open, but it still lets other sessions update the parent's non-key columns.

The plan is cached, but every row still pays for SPI setup, executor startup and teardown, snapshot handling and the permission checks, just to do one index lookup.

Postgres 18: every inserted row runs a small SQL queryRI_FKey_checkSPI_connectsaved planexecutor: SELECT 1 ... FOR KEY SHAREPostgres 19: the trigger reads the index itselfRI_FKey_checkri_FastPathCheckbtree probe + key-share lock
Same check, same lock. Postgres 19 removes everything between the trigger and the index.

The new path: probe the index directly

Amit Langote's commit 2da86c1ef9 adds a fast path. Before touching SPI, RI_FKey_check now asks two questions:

if (ri_fastpath_is_applicable(riinfo) &&
    ri_FastPathCheck(riinfo, fk_rel, newslot))
    return PointerGetDatum(NULL);

ri_fastpath_is_applicable says no for two cases: when the referenced table is partitioned (the probe would need to be routed to the right partition), and for temporal foreign keys (range containment needs more than a single lookup). Later fixes added more fallbacks: the referenced index has to be a btree, and its collation must match the column's.

If the answer is yes, ri_FastPathCheck does what the query used to do, by hand:

  1. Opens the parent table and its unique index.
  2. Takes a snapshot. In the shipped version this happens after the table lock is granted. Taking it earlier opened a window where a parent row committed while you waited for the lock would be invisible, and the check would fail for a key that exists (commit 1390182683).
  3. Builds scan keys from the new row's values and probes the btree with index_getnext_slot.
  4. Locks the row it found with table_tuple_lock(..., LockTupleKeyShare, ...) in ri_LockPKTuple. That's the same lock FOR KEY SHARE takes.
  5. If the lock had to follow an update chain to reach the newest row version, recheck_matched_pk_tuple checks that the key still matches. That is the fast path's version of the recheck the executor does for SELECT ... FOR UPDATE.

It also switches to the parent table owner's user id for the lookup, the same way the SPI path does, so permissions and row-level security behave the same.

The benchmark

A parent table with 1 million integer primary keys, and a child table with a foreign key to it. Each run inserts 1 million child rows in one INSERT ... SELECT, after a TRUNCATE and a CHECKPOINT. The same insert into a table with no foreign key is the baseline. Six runs per version; the charts show the median.

Scattered foreign key valuesPostgres 18.62,499 msPostgres 191,509 msmaster (batching)1,473 msno foreign key299 msSequential foreign key valuesPostgres 18.61,965 msPostgres 191,021 msmaster (batching)701 ms
Median of 6 runs, 1 million rows per insert. Lower is better.

With scattered keys, the foreign key check itself (the time above the no-foreign-key baseline) drops from 2.17 s in 18.6 to 1.21 s in 19. The whole insert is 1.66x faster.

The control: turn the fast path off

To check the gain really comes from the fast path, I ran the same test with a partitioned parent table. The fast path refuses partitioned parents, so 19 falls back to SPI:

Referenced table is partitioned (fast path does not apply)Postgres 18.67,808 msPostgres 197,395 ms
Partitioned parent, scattered keys. The gap nearly disappears (about 5%).

Two things show up here. The gain disappears when the fast path is off, so the fast path is what made the difference. And a foreign key to a partitioned table is expensive either way: about 3.5x slower than the SPI path on a plain table, because every check has to find the right partition first.

The batching that was pulled

Three days after the fast path, a second commit went further. Instead of probing the index once per row, it buffered up to 64 rows and checked them together. For single-column keys it passed all 64 values as one array scan key (SK_SEARCHARRAY), so the btree sorts them and walks its leaf pages once instead of descending from the root 64 times. The commit reported about 2.9x in total over the old code.

That batching is in the 19 betas but not in 19.0. On September 10 it was removed from REL_19_STABLE. Buffered checks have to survive across trigger calls and be flushed at the right moment, and that state has to be right under nested triggers, subtransactions, deferred constraints and SET CONSTRAINTS. Miss one case and a buffered check never runs: the transaction commits a real foreign key violation without an error. The commit message says it plainly: with 19 close to release, there wasn't time to be sure every case was covered. Batching stays on master for a later release. That's a hard call to make that close to release, and it's the right one.

My first measurement disagreed with the 2.9x. With scattered keys, master (batching included) is only about 2% faster than 19. The array scan only saves work when the 64 values land on the same few leaf pages. My keys were deliberately spread over the whole index, so every value still needed its own leaf.

So I reran with the keys in order (parent_id = 1, 2, 3, ...), which is what you get when child rows are loaded in parent order. Now batching does what the commit said: 701 ms against 1,021 ms for 19, and 2.8x against 18.6, close to the reported 2.9x.

Keys 18.6 19 master (batching)
Scattered 2,499 ms 1,509 ms 1,473 ms
Sequential 1,965 ms 1,021 ms 701 ms
Scattered, partitioned parent 7,808 ms 7,395 ms not run

What this means if you run Postgres

How I measured

All scripts, Dockerfiles and raw results are in the lab folder. Run ./run.sh <label> <image> and you get the same table.

Credit goes to Amit Langote, who wrote the fast path, the batching and roughly thirty follow-up commits to this code this year, and to everyone on pgsql-hackers who tested the betas hard enough to find the problems before release.