LATERAL lets a FROM item refer to columns from preceding items. It is useful when each parent needs a small, differently parameterized result, such as its two newest orders. The query remains one SQL statement and one database round trip, while the lateral expression states which outer values determine each result. It does not promise one particular physical execution strategy.
Keep the correlation visible
A subquery in FROM normally cannot reference a sibling table’s columns. LATERAL permits that reference on the appropriate side of the join. A condition such as o.customer_id = c.id is the connection between the current customer and its orders; the keyword alone does not create that condition. Without correlation, the same global result can be repeated for every parent.
For top-N lookups, place the parent’s filter, ORDER BY, and LIMIT inside the lateral subquery. A LIMIT outside the join restricts the final combined result, not the number of orders per customer. Add a unique tie-breaker to the ordering: two orders can share the same timestamp, and ordering only by that timestamp leaves their relative choice unspecified.
The subquery’s ORDER BY chooses its top rows, but does not guarantee final presentation order. Apply an outer ORDER BY when the client requires a stable sequence of customers and orders. This distinction matters for exported reports and tests, where relying on today’s apparent plan order creates intermittent changes after an upgrade or statistics refresh.
Decide what an empty result means
CROSS JOIN LATERAL drops a parent when its subquery returns no rows. LEFT JOIN LATERAL with ON true preserves that parent, filling the right-hand columns with nulls. This is often the required behavior for customers who have not placed an order. A right-side condition moved into the outer WHERE can reject that null-extended row and undo the intended preservation.
Aggregation adds another distinction. COUNT(r.id) counts non-null matched order identifiers; COUNT(*) also counts a left join’s synthetic row. A correlated aggregate without GROUP BY can itself return one row for an empty input, with COUNT zero or SUM null. Read the returned row shape rather than treating every lateral expression as an identical list lookup.
When it breaks
Removing the outer reference to make a syntax error disappear changes the query’s meaning. Every customer may receive the same newest orders, and inflated totals can still look plausible. Test parents with no matches, one match, several matches, and tied ordering values. An assertion about exact identifiers is stronger than checking that the query ran successfully.
Performance can also deteriorate when an expensive inner operation repeats for many outer rows. An index beginning with the correlation key and continuing with the requested ordering can support selective top-N lookups. It is not guaranteed to be chosen, especially for tiny tables. Read EXPLAIN’s actual loops and rows, and compare a window-function formulation when processing a large fraction of the child table. LATERAL removes client round trips; it does not erase database work.
Check it quickly
Save the following as lateral.sql and run psql -X -v ON_ERROR_STOP=1 -f lateral.sql against a disposable PostgreSQL 18 database. It creates three customers, four orders, a matching index, and a temporary view inside one transaction.
\set ON_ERROR_STOP on
BEGIN;
SET LOCAL statement_timeout = '5s';
CREATE TEMP TABLE customers (id integer PRIMARY KEY, name text);
CREATE TEMP TABLE orders (id integer PRIMARY KEY, customer_id integer, placed_at date);
INSERT INTO customers VALUES (1,'Ada'), (2,'Lin'), (3,'Sam');
INSERT INTO orders VALUES
(10,1,'2026-09-01'), (11,1,'2026-09-02'), (12,1,'2026-09-02'),
(20,2,'2026-09-01');
CREATE INDEX orders_recent ON orders (customer_id, placed_at DESC, id DESC);
ANALYZE customers;
ANALYZE orders;
CREATE TEMP VIEW recent AS
SELECT c.id AS customer_id, r.id AS order_id
FROM customers c LEFT JOIN LATERAL (
SELECT o.id FROM orders o WHERE o.customer_id=c.id
ORDER BY o.placed_at DESC, o.id DESC LIMIT 2
) r ON true;
SELECT * FROM recent ORDER BY customer_id, order_id DESC;
SELECT c.id, count(r.id) AS selected_orders
FROM customers c LEFT JOIN LATERAL (
SELECT o.id FROM orders o WHERE o.customer_id=c.id
ORDER BY o.placed_at DESC, o.id DESC LIMIT 2
) r ON true GROUP BY c.id ORDER BY c.id;
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT * FROM recent;
DO $$ BEGIN
IF (SELECT array_agg(order_id ORDER BY order_id) FROM recent WHERE customer_id=1)
IS DISTINCT FROM ARRAY[11,12] THEN
RAISE EXCEPTION 'top two or tie-breaker is wrong';
END IF;
IF NOT EXISTS (SELECT FROM recent WHERE customer_id=3 AND order_id IS NULL) THEN
RAISE EXCEPTION 'LEFT JOIN lost the customer without orders';
END IF;
IF (SELECT count(*) FROM recent) <> 4 THEN RAISE EXCEPTION 'wrong row count'; END IF;
END $$;
ROLLBACK;
The result contains (1,12), (1,11), (2,20), and customer three with a null order. Counts of matched orders are two, one, and zero. The tied date for orders eleven and twelve exercises the identifier tie-breaker; customer three exercises empty-result preservation. Assertions verify the identifiers and total row count, and the final rollback removes all objects.
PostgreSQL 18.6 used repeated small scans for this fixture, which is reasonable for four orders. That observation does not prove the proposed index is ineffective at scale. Test production-shaped parent counts and child distributions before drawing a performance conclusion. The five-second statement timeout bounds the experiment, while correctness depends on correlation, join type, and deterministic ordering.
Sources
- PostgreSQL table expressions, lateral references and outer join semantics, checked 2026-09-10.
- PostgreSQL SELECT, ordering and limit behavior, checked 2026-09-10.