PostgreSQL LATERAL Joins: Top-N Per Group, unnest Expansion, and Correlated Subqueries
Master PostgreSQL LATERAL joins for advanced query patterns. Covers LATERAL subquery for top-N per group, LEFT JOIN LATERAL for optional correlated data, unnest with LATERAL for array expansion, LATERAL with functions, and performance comparison with alternatives.
LATERAL joins let a subquery reference columns from tables earlier in the FROM clause — making it possible to run a correlated subquery for each row of the outer table. The most useful application is top-N per group: the top 3 most recent orders per customer, the top 5 trending posts per category, the latest event per user. Without LATERAL, these require window functions or complex subqueries; with LATERAL, they're clean and fast.
Top-N Per Group: The Classic Use Case
-- Scenario: get the 3 most recent orders for each customer
-- This is the prototypical "top-N per group" problem
-- ❌ Approach 1: Subquery with IN — doesn't limit to N per group
SELECT *
FROM orders
WHERE customer_id IN (SELECT id FROM customers WHERE plan = 'growth');
-- ❌ Approach 2: Window function — works but filters AFTER the join
SELECT *
FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC) AS rn
FROM orders
) ranked
WHERE rn <= 3;
-- Problem: scans all orders, then filters (no index-per-customer benefit)
-- ✅ Approach 3: LATERAL — index scan per customer, stops after 3 rows
SELECT
c.id AS customer_id,
c.name,
c.email,
o.id AS order_id,
o.amount_cents,
o.created_at
FROM customers c
JOIN LATERAL (
SELECT id, amount_cents, created_at
FROM orders
WHERE customer_id = c.id -- References outer table column
ORDER BY created_at DESC
LIMIT 3 -- Stop after 3 rows per customer
) o ON TRUE -- Always join (inner join equivalent)
WHERE c.workspace_id = $1
ORDER BY c.id, o.created_at DESC;
-- The planner can use the index on orders(customer_id, created_at DESC)
-- and do a fast index scan for each customer — much more efficient than
-- scanning all orders and then filtering
LEFT JOIN LATERAL: When Rows May Have No Results
-- LEFT JOIN LATERAL: include customers even if they have no orders
SELECT
c.id,
c.name,
o.id AS latest_order_id,
o.amount_cents AS latest_order_amount,
o.created_at AS latest_order_at
FROM customers c
LEFT JOIN LATERAL (
SELECT id, amount_cents, created_at
FROM orders
WHERE customer_id = c.id
ORDER BY created_at DESC
LIMIT 1
) o ON TRUE -- ON TRUE required (the correlation is in WHERE, not ON)
WHERE c.workspace_id = $1;
-- Without LEFT JOIN LATERAL, customers with no orders would be excluded
-- With LEFT JOIN LATERAL, they appear with NULL order columns
☁️ Is Your Cloud Costing Too Much?
Most teams overspend 30–40% on cloud — wrong instance types, no reserved pricing, bloated storage. We audit, right-size, and automate your infrastructure.
- AWS, GCP, Azure certified engineers
- Infrastructure as Code (Terraform, CDK)
- Docker, Kubernetes, GitHub Actions CI/CD
- Typical audit recovers $500–$3,000/month in savings
LATERAL for Top Events Per Workspace
-- Dashboard: most frequent events for each workspace in last 7 days
SELECT
w.id AS workspace_id,
w.name,
top_events.name AS event_name,
top_events.count
FROM workspaces w
JOIN LATERAL (
SELECT name, COUNT(*) AS count
FROM events
WHERE workspace_id = w.id
AND occurred_at >= NOW() - INTERVAL '7 days'
GROUP BY name
ORDER BY count DESC
LIMIT 5
) top_events ON TRUE
WHERE w.plan IN ('growth', 'enterprise')
ORDER BY w.id, top_events.count DESC;
unnest with LATERAL: Expand Arrays Into Rows
-- Workspace has a tags TEXT[] column
-- Find all workspaces that use each tag, exploding the array
CREATE TABLE workspaces (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}'
);
-- Without LATERAL: unnest works but loses row context without explicit reference
SELECT id, unnest(tags) AS tag FROM workspaces; -- Works but limited
-- With LATERAL: explicit and joins cleanly with other tables
SELECT
w.id,
w.name,
t.tag
FROM workspaces w,
LATERAL unnest(w.tags) AS t(tag) -- Generates one row per tag element
WHERE 'postgresql' = ANY(w.tags) -- Filter first (index on GIN tags)
ORDER BY w.id, t.tag;
-- Equivalent using JOIN LATERAL (same result):
SELECT
w.id,
w.name,
t.tag
FROM workspaces w
JOIN LATERAL unnest(w.tags) AS t(tag) ON TRUE;
-- Count tag usage across all workspaces:
SELECT
t.tag,
COUNT(DISTINCT w.id) AS workspace_count
FROM workspaces w,
LATERAL unnest(w.tags) AS t(tag)
GROUP BY t.tag
ORDER BY workspace_count DESC
LIMIT 20;
⚙️ DevOps Done Right — Zero Downtime, Full Automation
Ship faster without breaking things. We build CI/CD pipelines, monitoring stacks, and auto-scaling infrastructure that your team can actually maintain.
- Staging + production environments with feature flags
- Automated security scanning in the pipeline
- Uptime monitoring + alerting + runbook automation
- On-call support handover docs included
LATERAL with Set-Returning Functions
-- generate_series with LATERAL: create date ranges per row
SELECT
p.id AS project_id,
p.name,
days.d AS day
FROM projects p,
LATERAL generate_series(
p.start_date,
COALESCE(p.end_date, CURRENT_DATE),
'1 day'::interval
) AS days(d)
WHERE p.workspace_id = $1
AND p.status = 'active'
ORDER BY p.id, days.d;
-- This generates one row per day for each project's duration
-- Useful for building calendar heatmaps or Gantt chart data
LATERAL for Pagination With Context
-- Cursor pagination with additional context per page item
SELECT
t.id,
t.title,
t.status,
t.created_at,
-- LATERAL subquery for related data without separate query
assignee.name AS assignee_name,
comment_count.n AS comments
FROM tasks t
JOIN LATERAL (
SELECT name FROM users WHERE id = t.assignee_id
) assignee ON TRUE
LEFT JOIN LATERAL (
SELECT COUNT(*) AS n FROM task_comments WHERE task_id = t.id
) comment_count ON TRUE
WHERE t.project_id = $1
AND t.created_at < $2 -- Cursor
ORDER BY t.created_at DESC
LIMIT 25;
Performance: When LATERAL is Faster
-- Use EXPLAIN to compare approaches
-- Setup: index exists on orders(customer_id, created_at DESC)
-- Approach A: Window function
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC) AS rn
FROM orders WHERE workspace_id = $1
) r WHERE rn <= 3;
-- Likely: Seq Scan or large index scan → sort → window filter
-- Approach B: LATERAL
EXPLAIN (ANALYZE, BUFFERS)
SELECT c.id, o.*
FROM customers c
JOIN LATERAL (
SELECT * FROM orders WHERE customer_id = c.id ORDER BY created_at DESC LIMIT 3
) o ON TRUE
WHERE c.workspace_id = $1;
-- Likely: Index Scan on customers → Nested Loop → Index Scan on orders per customer
-- Much faster when customers are few and orders index is selective
TypeScript with Prisma: LATERAL via $queryRaw
// lib/queries/top-orders.ts
import { prisma } from "@/lib/prisma";
interface CustomerWithTopOrders {
customerId: string;
customerName: string;
orderId: string;
amountCents: number;
createdAt: Date;
}
export async function getTopOrdersPerCustomer(
workspaceId: string,
ordersPerCustomer = 3
): Promise<CustomerWithTopOrders[]> {
return prisma.$queryRaw<CustomerWithTopOrders[]>`
SELECT
c.id AS "customerId",
c.name AS "customerName",
o.id AS "orderId",
o.amount_cents AS "amountCents",
o.created_at AS "createdAt"
FROM customers c
JOIN LATERAL (
SELECT id, amount_cents, created_at
FROM orders
WHERE customer_id = c.id
ORDER BY created_at DESC
LIMIT ${ordersPerCustomer}
) o ON TRUE
WHERE c.workspace_id = ${workspaceId}::uuid
ORDER BY c.id, o.created_at DESC
`;
}
// unnest expansion via $queryRaw
export async function getTagUsageStats(workspaceId: string) {
return prisma.$queryRaw<{ tag: string; count: bigint }[]>`
SELECT t.tag, COUNT(DISTINCT w.id) AS count
FROM workspaces w,
LATERAL unnest(w.tags) AS t(tag)
WHERE w.id IN (
SELECT id FROM workspaces WHERE parent_id = ${workspaceId}::uuid
)
GROUP BY t.tag
ORDER BY count DESC
LIMIT 20
`;
}
When to Use LATERAL vs Alternatives
| Pattern | LATERAL | Window Function | Correlated Subquery |
|---|---|---|---|
| Top-N per group | ✅ Best (index per group) | ✅ Works | ❌ N+1 |
| Single latest row per group | ✅ | ✅ DISTINCT ON faster | ❌ |
| Array expansion | ✅ Clean syntax | N/A | N/A |
| Optional related data | ✅ LEFT JOIN LATERAL | N/A | ✅ |
| Aggregate per row | ✅ | N/A | ✅ (if simple) |
Cost and Timeline Estimates
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Top-N LATERAL query + index setup | 1 dev | Half a day | $150–300 |
| unnest expansion + array analytics | 1 dev | Half a day | $150–300 |
| Full query optimization engagement | 1–2 devs | 2–3 days | $800–1,500 |
See Also
- PostgreSQL Window Functions
- PostgreSQL CTE and Recursive Queries
- PostgreSQL Performance Tuning
- PostgreSQL Statistics and ANALYZE
- PostgreSQL Indexing Advanced Patterns
Working With Viprasol
LATERAL joins unlock query patterns that are either impossible or prohibitively slow with standard joins — particularly top-N per group, where every other approach either scans the whole table or generates N+1 queries. Our team uses LATERAL as the default for recent-record-per-row queries (latest order per customer, top event per workspace) when the index exists on the correlation column.
What we deliver:
- Top-N LATERAL:
JOIN LATERAL (SELECT ... WHERE customer_id = c.id ORDER BY ... LIMIT N) o ON TRUE LEFT JOIN LATERALfor optional correlated data with NULL when no matchunnest(tags) AS t(tag)expansion with LATERAL for array-to-rows with GIN index filtergenerate_serieswith LATERAL for date range expansion per project- EXPLAIN comparison: window function vs LATERAL to demonstrate index scan benefit
$queryRawTypeScript helpers:getTopOrdersPerCustomerandgetTagUsageStats
Talk to our team about your PostgreSQL query optimization →
Or explore our cloud and data engineering services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need DevOps & Cloud Expertise?
Scale your infrastructure with confidence. AWS, GCP, Azure certified team.
Free consultation • No commitment • Response within 24 hours
Making sense of your data at scale?
Viprasol builds end-to-end big data analytics solutions — ETL pipelines, data warehouses on Snowflake or BigQuery, and self-service BI dashboards. One reliable source of truth for your entire organisation.