1Intent is visible
FOR KEY says this is not just matching values. It is following FK columns to referenced columns.
JOIN ... FOR KEY is a foreign-key-aware equijoin with a compile-time proof of correctness.
If the DBMS can prove the join follows a declared referential constraint and preserves every referencing row exactly once, it prepares the query. Otherwise, it fails before execution — an invalid key join never gets through.
The guarantee — if it compiles, it's correct.
FOR KEY accepts a key join only when it can prove it correct against the mathematical conditions, and rejects every one it cannot prove. So a key join that compiles preserves each referencing row exactly once and enriches it through the declared key — nothing invalid compiles.
The proof is conservative: it can reject a join your current data happens to satisfy, but it never accepts a wrong one.
The short version
Key joins do not add a new runtime algorithm. They add a way for the query to declare, locally, that a join is following a referential relationship.
FOR KEY says this is not just matching values. It is following FK columns to referenced columns.
The arrow always points from the referencing side to the referenced side, independent of table order.
The DBMS proves each key join from declarations and query structure, accepting it only if correct and rejecting every one it cannot prove.
Once accepted, the join runs like the equivalent equijoin. The new value is the compile-time contract.
Syntax
The traditional join says two values are equal; the key join says which referential constraint the equality follows. Here are three tables — order_items, orders, customers — joined two ways, each with the same result.
SELECT c.name, o.order_date, oi.amount
FROM order_items AS oi
JOIN orders AS o ON o.id = oi.order_id
JOIN customers AS c ON c.id = o.customer_id;
SELECT c.name, o.order_date, oi.amount
FROM order_items AS oi
JOIN orders AS o FOR KEY oi (order_id) -> o (id)
JOIN customers AS c FOR KEY o (customer_id) -> c (id);
SELECT c.name, o.order_date, oi.amount
FROM customers AS c
JOIN orders AS o ON o.customer_id = c.id
JOIN order_items AS oi ON oi.order_id = o.id;
SELECT c.name, o.order_date, oi.amount
FROM customers AS c
JOIN orders AS o FOR KEY c (id) <- o (customer_id)
JOIN order_items AS oi FOR KEY o (id) <- oi (order_id);
Why name both operands? The first qualified column list names an exposed reference in the left operand; the second names one in the right operand. Either name can be an alias, a table or CTE name, or a qualified table name such as sales.orders. An alias hides the original name.
Either operand can be a parenthesized join. Its exposed names identify the columns, while the proof checks the entire operand result. The arrow always points from the referencing columns to the referenced columns.
Same query, plus a proof. Each FOR KEY clause is exactly the equijoin beside it — e.g. o.id = oi.order_id — with the same output columns. It only adds the compile-time proof that the join follows that foreign key.
-> |
The newly joined table is the referenced side, when enriching a row with lookup data. |
|---|---|
<- |
The newly joined table is the referencing side, when expanding from one row to its children. |
Result shape
For INNER joins and outer joins that preserve only the referencing operand, the result contains exactly one row per referencing row. The diagrams show a lookup or an expansion from parents to children; outer joins that also preserve the referenced operand can add unmatched referenced rows.
-> enrich — row count unchangedrows so far
result
Each row keeps its place and gains its one matching referenced row. The newly joined table is the referenced side.
<- descend — rows can multiplyrows so far
result
Each row expands to its children — the only direction where the running count grows. The newly joined table is the referencing side.
-> joins, all INNER or LEFT: the result stays 1 : 1 with the first table in the FROM clause.Four forms are guaranteed 1 : 1 with the referencing side — the ones that do not add unmatched referenced rows:
The rule: the outer-preserved side, if any, must be the referencing side.
The proof
A key join is correct exactly when these three hold. The DBMS proves all three at compile time, or rejects the query — it never accepts a key join it cannot prove.
Check 1
Uniqueness of all-non-null referenced keys.
Check 2
Match existence for every all-non-null referencing key.
Check 3
At least one of the following:
a) All referencing key values are non-null.
b) The <join type> preserves the referencing operand.
Rejected at compile time
These aren't heuristics for known-bad patterns. Each query below fails the proof, so it never compiles — and anything FOR KEY cannot prove correct is rejected the same way. Every example is complete: create the tables, run the query, and you get the exact error shown.
Composite keys are a classic trap: forget one column and the join looks right on small test data, then quietly returns duplicates in production.
CREATE TABLE hotels (
id integer PRIMARY KEY
);
CREATE TABLE rooms (
hotel_id integer NOT NULL REFERENCES hotels (id),
room_number integer NOT NULL,
PRIMARY KEY (hotel_id, room_number)
);
CREATE TABLE reservations (
id integer PRIMARY KEY,
hotel_id integer NOT NULL,
room_number integer NOT NULL,
FOREIGN KEY (hotel_id, room_number) REFERENCES rooms (hotel_id, room_number)
);
SELECT *
FROM reservations AS res
JOIN rooms AS r FOR KEY res (room_number) -> r (room_number);
ERROR: key join from referencing relation res to referenced relation r cannot be proven
LINE 3: JOIN rooms AS r FOR KEY res (room_number) -> r (room_number);
^
DETAIL: There is no matching foreign key constraint for res (room_number) referencing r (room_number).
Fix: name both key columns — FOR KEY res (hotel_id, room_number) -> r (hotel_id, room_number).
Joining one parent to two child tables multiplies its rows, so the second key join can no longer prove the parent unique — exactly what would have doubled the sums.
CREATE TABLE orders (
id integer PRIMARY KEY
);
CREATE TABLE order_items (
id integer PRIMARY KEY,
order_id integer NOT NULL REFERENCES orders (id),
amount numeric
);
CREATE TABLE payments (
id integer PRIMARY KEY,
order_id integer NOT NULL REFERENCES orders (id),
amount numeric
);
SELECT o.id, sum(oi.amount) AS item_total, sum(p.amount) AS payment_total
FROM orders AS o
LEFT JOIN order_items AS oi FOR KEY o (id) <- oi (order_id)
LEFT JOIN payments AS p FOR KEY o (id) <- p (order_id)
GROUP BY o.id;
ERROR: key join from referencing relation p to referenced relation o cannot be proven
LINE 4: LEFT JOIN payments AS p FOR KEY o (id) <- p (order_id)
^
DETAIL: Referenced columns o (id) are not proven unique. A preceding join may duplicate rows from referenced relation o.
Fix: pre-aggregate order_items and payments to one row per order_id, then key-join those subqueries to orders.
The view hides inactive customers, so an order pointing at one has nowhere to land. An ordinary join would silently drop it; the key join refuses.
CREATE TABLE customers (
id integer PRIMARY KEY,
active boolean NOT NULL DEFAULT true
);
CREATE TABLE orders (
id integer PRIMARY KEY,
customer_id integer NOT NULL REFERENCES customers (id)
);
CREATE VIEW active_customers AS
SELECT * FROM customers WHERE active;
SELECT *
FROM orders AS o
JOIN active_customers AS ac FOR KEY o (customer_id) -> ac (id);
ERROR: key join from referencing relation o to referenced relation ac cannot be proven
LINE 3: JOIN active_customers AS ac FOR KEY o (customer_id) -> ac (id);
^
DETAIL: Not every o (customer_id) value can be proven to have a matching ac row. Referenced relation ac is filtered before this key join. The relevant operation occurs inside view public.active_customers.
Fix: key-join the base customers table and filter in WHERE, or, using the PostgreSQL prototype's additional proof support, give both sides the same key filter.
home_dept is optional, so a NULL matches nothing. An inner join would quietly drop employees who have no home department.
CREATE TABLE departments (
id integer PRIMARY KEY
);
CREATE TABLE employees (
id integer PRIMARY KEY,
home_dept integer REFERENCES departments (id) -- nullable
);
SELECT *
FROM employees AS e
JOIN departments AS d FOR KEY e (home_dept) -> d (id);
ERROR: key join from referencing relation e to referenced relation d cannot be proven
LINE 3: JOIN departments AS d FOR KEY e (home_dept) -> d (id);
^
DETAIL: This inner join could filter rows from e. Referencing columns e (home_dept) can be null.
Fix: use LEFT JOIN departments AS d FOR KEY e (home_dept) -> d (id) to keep employees with no home department (null-extended).
Views and CTEs
A query author should not need to care whether they are joining a base table, a view, or a CTE. The schema designer owns the derived table; the DBMS checks whether it still exposes the facts a key join needs.
id is fine; hiding it inside COALESCE, casts, or arbitrary expressions breaks the proof.CREATE VIEW department_directory AS
SELECT id, name
FROM departments;
SELECT e.name, dd.name
FROM employees AS e
JOIN department_directory AS dd
FOR KEY e (dept_id) -> dd (id);
Design calls
The syntax is intentionally explicit. It is optimized for local readability, schema evolution, and stored definitions that fail loudly when their assumptions break.
They make FK direction visible at the join site. In a chain of -> joins, each INNER or LEFT, the first table's rows are preserved end-to-end.
Constraint-name and inference-based designs get ambiguous with aliases, views, multiple FKs, and schema changes. Column lists state the contract directly.
Runtime checks depend on today's data. Key joins prove the structure before the query runs, so bad joins do not wait for production data to reveal themselves.
A key join follows one referential constraint. If the path is A -> B -> C, write both joins so each step is readable and independently checked.