Truy vấn nâng caoAdvanced Querying
Thuộc bộ kiến thức PostgreSQL DBA Roadmap.
Tổng quan
Các câu lệnh SELECT ... WHERE ... JOIN cơ bản đã giải quyết được nhiều bài toán, nhưng báo cáo, phân tích dữ liệu, và các bài toán dữ liệu phân cấp trong thực tế đòi hỏi một bộ công cụ phong phú hơn. Bài viết này trình bày các cấu trúc phân biệt người “biết viết query” với người có thể diễn đạt logic nghiệp vụ phức tạp một cách khai báo (declarative) và hiệu quả bằng SQL: set operations, subquery, Common Table Expression (CTE), recursive CTE, LATERAL join, và window function.
Đây không chỉ là cú pháp cho đẹp. Chọn đúng công cụ có ảnh hưởng thực sự đến hiệu năng — một subquery dùng IN và một subquery EXISTS tương đương về ngữ nghĩa có thể tạo ra hai query plan rất khác nhau, và CTE trong PostgreSQL trước phiên bản 12 từng là rào cản tối ưu hóa (optimization barrier) cứng, điều khiến không ít developer quen với database khác phải bất ngờ. Hiểu rõ những chi tiết này giúp bạn viết ra query vừa dễ đọc vừa nhanh, đồng thời chuẩn bị nền tảng cho chủ đề tiếp theo trong loạt bài này — query planning và performance tuning — nơi ta xem xét PostgreSQL thực thi các cấu trúc này như thế nào ở tầng dưới.
Bài viết giả định bạn đã quen với SQL cơ bản như trong 04 — Querying Data / SQL Fundamentals. Về cách planner biến các query này thành execution plan (và cách đọc output của EXPLAIN cho chúng), xem 10 — Query Planning and Performance Tuning.
Xuyên suốt bài viết, ta dùng schema mẫu sau:
CREATE TABLE customers (
customer_id serial PRIMARY KEY,
name text NOT NULL,
country text NOT NULL
);
CREATE TABLE orders (
order_id serial PRIMARY KEY,
customer_id int NOT NULL REFERENCES customers(customer_id),
order_date date NOT NULL,
amount numeric(10,2) NOT NULL
);
CREATE TABLE employees (
employee_id int PRIMARY KEY,
name text NOT NULL,
manager_id int REFERENCES employees(employee_id),
department text NOT NULL,
salary numeric(10,2) NOT NULL
);
Kiến thức nền tảng
Set operations: UNION, INTERSECT, EXCEPT
Set operation kết hợp kết quả của hai hay nhiều câu SELECT có cùng số cột và kiểu dữ liệu tương thích. PostgreSQL hỗ trợ ba toán tử set, mỗi toán tử có biến thể “distinct” (mặc định) và “all”.
UNION gộp các dòng từ hai query và loại bỏ trùng lặp theo mặc định:
SELECT name, country FROM customers WHERE country = 'Vietnam'
UNION
SELECT name, country FROM customers WHERE country = 'Singapore';
UNION ALL làm điều tương tự nhưng giữ lại các dòng trùng lặp:
SELECT name, country FROM customers WHERE country = 'Vietnam'
UNION ALL
SELECT name, country FROM customers WHERE country = 'Singapore';
Lý do UNION ALL nhanh hơn UNION không phải là một tối ưu vặt vãnh — đó là một sự khác biệt căn bản về thuật toán. UNION phải sort hoặc hash toàn bộ tập kết quả gộp để xác định và loại bỏ các dòng trùng lặp, tức là thêm một lượt duyệt toàn bộ dữ liệu (thể hiện trong EXPLAIN bằng node HashAggregate hoặc Unique phía trên Append). UNION ALL chỉ đơn giản nối các tập dòng lại bằng node Append và không thực hiện bất kỳ công việc khử trùng lặp nào. Nếu bạn đã biết chắc hai query không thể tạo ra dòng trùng lặp (như ví dụ trên, khi country loại trừ lẫn nhau), hãy luôn ưu tiên UNION ALL — kết quả đúng vẫn giống hệt, nhưng bạn tiết kiệm được một lượt xử lý tốn kém.
INTERSECT chỉ trả về các dòng xuất hiện ở cả hai tập kết quả:
-- Khách hàng đặt đơn cả trong năm 2023 VÀ 2024
SELECT customer_id FROM orders WHERE order_date >= '2023-01-01' AND order_date < '2024-01-01'
INTERSECT
SELECT customer_id FROM orders WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01';
Cách này hữu ích cho phân tích kiểu “thuộc cả hai nhóm” — ví dụ, tìm khách hàng hoạt động trong hai giai đoạn khác nhau, hoặc sản phẩm xuất hiện trong hai catalog khác nhau.
EXCEPT trả về các dòng từ query đầu tiên mà không xuất hiện trong query thứ hai (phép hiệu tập hợp, thứ tự có ý nghĩa):
-- Khách hàng đặt đơn năm 2023 nhưng KHÔNG đặt đơn năm 2024 (churn)
SELECT customer_id FROM orders WHERE order_date >= '2023-01-01' AND order_date < '2024-01-01'
EXCEPT
SELECT customer_id FROM orders WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01';
EXCEPT là công cụ tự nhiên cho việc “tìm cái đang thiếu” hoặc phân tích churn: các dòng có mặt trong tập tham chiếu nhưng vắng mặt trong tập so sánh. Cả ba toán tử đều có biến thể ALL (INTERSECT ALL, EXCEPT ALL) dùng ngữ nghĩa multiset (so khớp số lượng bản sao) thay vì khử trùng lặp, tuy nhiên trong thực tế ít được dùng hơn nhiều.
Subquery
Subquery là một câu SELECT được lồng bên trong một câu lệnh khác. Subquery có vài dạng khác nhau, ảnh hưởng đến cả khả năng đọc hiểu lẫn hiệu năng.
Scalar subquery trả về đúng một dòng và một cột, có thể dùng ở bất cứ đâu cần một giá trị đơn:
SELECT name,
(SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.customer_id) AS order_count
FROM customers c;
Nếu một scalar subquery bất ngờ trả về nhiều hơn một dòng lúc chạy, PostgreSQL sẽ báo lỗi (more than one row returned by a subquery used as an expression), vì vậy chỉ nên dùng khi bạn đảm bảo được tối đa một dòng khớp.
Subquery trong mệnh đề WHERE với IN kiểm tra tư cách thành viên trong một tập:
SELECT name FROM customers
WHERE customer_id IN (SELECT customer_id FROM orders WHERE amount > 1000);
Đây là một uncorrelated subquery (subquery không tương quan) — query bên trong không tham chiếu bất cứ thứ gì từ query bên ngoài, nên về nguyên tắc PostgreSQL có thể thực thi nó một lần và tái sử dụng kết quả.
Correlated subquery (subquery tương quan) tham chiếu một cột từ query bên ngoài bên trong query bên trong, do đó về mặt logic nó được đánh giá lại (hoặc ít nhất được xem là đánh giá lại) cho từng dòng bên ngoài:
-- Khách hàng có đơn hàng gần nhất cao hơn mức trung bình lịch sử của chính họ
SELECT c.name
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
AND o.amount > (
SELECT AVG(o2.amount) FROM orders o2 WHERE o2.customer_id = c.customer_id
)
);
Ở đây, subquery trong cùng tương quan với c.customer_id từ query ngoài cùng, và subquery EXISTS ở giữa cũng tương quan với c.customer_id. Về mặt logic, mỗi subquery được đánh giá lại cho từng khách hàng ứng viên, dù planner có thể viết lại thành một dạng kế hoạch giống join ở bên trong.
IN so với EXISTS so với NOT EXISTS
IN và EXISTS thường trông có thể thay thế lẫn nhau, nhưng chúng hoạt động khác nhau, đặc biệt khi dữ liệu lớn:
-- Dùng IN
SELECT name FROM customers c
WHERE c.customer_id IN (SELECT o.customer_id FROM orders o WHERE o.amount > 500);
-- Dùng EXISTS (correlated)
SELECT name FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.customer_id AND o.amount > 500
);
Cả hai query trả về cùng kết quả, nhưng chiến lược thực thi khác nhau. Về mặt khái niệm, IN cần toàn bộ danh sách giá trị khớp từ subquery trước khi có thể kiểm tra tư cách thành viên; trước đây, và trong một số dạng plan hiện nay vẫn vậy, điều này đồng nghĩa với việc phải materialize (hoặc khử trùng lặp) toàn bộ kết quả subquery trước khi dò cho từng dòng bên ngoài. Ngược lại, EXISTS là một phép kiểm tra tồn tại: PostgreSQL có thể dừng quét query bên trong ngay khi tìm thấy một dòng khớp cho dòng ngoài hiện tại (một “semi-join” có hành vi short-circuit), thay vì phải tính toàn bộ tập kết quả bên trong.
Trong PostgreSQL hiện đại (9.x trở lên), planner thường đủ thông minh để viết lại subquery IN thành một semi-join plan có hiệu năng tương đương EXISTS, đặc biệt khi subquery chỉ là một phép so sánh bằng đơn giản, có index. Nhưng việc viết lại này không được đảm bảo trong mọi trường hợp — nó phụ thuộc vào hình dạng subquery, mức độ tương quan, và việc planner có nhận ra đây là biến đổi an toàn hay không. EXISTS cho bạn một đảm bảo mạnh hơn về hành vi short-circuit và thường ổn định hơn khi:
- Subquery có thể trả về tập kết quả rất lớn hoặc nhiều trùng lặp (tránh bước materialize/khử trùng lặp trung gian tốn kém mà
INcó thể yêu cầu). - Subquery tương quan và phức tạp (aggregate, nhiều join) thay vì chỉ là một tham chiếu cột đơn giản.
- Bạn cần
NOT EXISTSthay vìNOT IN— sự khác biệt này còn quan trọng hơn về mặt đúng đắn, không chỉ hiệu năng: nếu subquery trongNOT INcó thể trả về bất kỳNULLnào, toàn bộ biểu thứcNOT INsẽ được đánh giá thànhNULL(không đúng cũng không sai) cho mọi dòng bên ngoài, âm thầm trả về 0 dòng.NOT EXISTSkhông có cạm bẫy này:
-- Khách hàng chưa từng đặt đơn hàng nào
-- NOT EXISTS: an toàn, luôn đúng
SELECT c.name FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id);
-- NOT IN: nguy hiểm nếu orders.customer_id có thể là NULL
SELECT c.name FROM customers c
WHERE c.customer_id NOT IN (SELECT o.customer_id FROM orders o); -- trả về 0 dòng nếu có bất kỳ o.customer_id nào IS NULL
Quy tắc thực tế: ưu tiên EXISTS/NOT EXISTS cho các kiểm tra tồn tại tương quan và bất cứ khi nào tính an toàn với NULL quan trọng, và đừng mặc định IN luôn chậm hơn — luôn kiểm tra bằng EXPLAIN ANALYZE trên khối lượng dữ liệu thực tế của bạn (xem 10 — Query Planning and Performance Tuning).
Khái niệm chính
Common Table Expression (CTE)
CTE, được khai báo bằng mệnh đề WITH, cho phép bạn đặt tên cho một subquery và tham chiếu lại nó sau đó trong câu lệnh như thể đó là một bảng. Lợi ích chính là khả năng đọc hiểu: thay vì lồng chín cấp subquery, bạn chia một query phức tạp thành các bước logic có tên, tuần tự.
WITH high_value_customers AS (
SELECT customer_id, SUM(amount) AS total_spent
FROM orders
GROUP BY customer_id
HAVING SUM(amount) > 5000
),
recent_orders AS (
SELECT customer_id, order_id, order_date, amount
FROM orders
WHERE order_date >= CURRENT_DATE - INTERVAL '90 days'
),
combined AS (
SELECT hvc.customer_id, hvc.total_spent, ro.order_id, ro.order_date, ro.amount
FROM high_value_customers hvc
JOIN recent_orders ro ON ro.customer_id = hvc.customer_id
)
SELECT c.name, combined.total_spent, combined.order_id, combined.order_date, combined.amount
FROM combined
JOIN customers c ON c.customer_id = combined.customer_id
ORDER BY combined.total_spent DESC, combined.order_date DESC;
Mỗi khối WITH đọc như một bước trong một pipeline: đầu tiên tìm khách hàng giá trị cao, sau đó tìm đơn hàng gần đây, rồi join chúng lại, cuối cùng bổ sung tên khách hàng. Điều này dễ review và chỉnh sửa hơn rất nhiều so với một query tương đương lồng chín cấp dấu ngoặc, và mỗi CTE có tên có thể được kiểm tra độc lập bằng cách chạy nó như một SELECT đứng riêng trong lúc phát triển query.
Rào cản tối ưu hóa CTE của PostgreSQL (materialization) — một chi tiết đặc thù đáng lưu ý
Đây là một trong những chi tiết đặc thù của PostgreSQL quan trọng nhất đối với ai đến từ một RDBMS khác, và nó đã thay đổi đáng kể qua các phiên bản:
- PostgreSQL trước phiên bản 12: mỗi CTE trong mệnh đề
WITHlà một rào cản tối ưu hóa (optimization fence). Planner luôn materialize kết quả của CTE (tính toán đầy đủ, tràn ra vùng làm việc tạm) trước khi dùng nó trong query ngoài, và không thể đẩy điều kiện lọc hay điều kiện join xuống bên trong CTE. Điều này khiến CTE trở thành một cái bẫy hiệu năng phổ biến — một query có điều kiệnWHEREtrên cột của CTE lẽ ra có thể được đẩy xuống và dùng index, thì lại phải quét toàn bộ, không lọc, CTE trước. - PostgreSQL 12 trở lên: CTE không đệ quy, theo mặc định, được inline giống như subquery hay view bất cứ khi nào planner xác định điều đó an toàn và có lợi — rào cản đã biến mất theo mặc định. Planner có thể đẩy predicate vào bên trong, chọn dùng index trên các bảng gốc, và gộp CTE vào kế hoạch truy vấn xung quanh giống hệt như với một subquery tương đương.
- Bạn vẫn có thể ép hành vi materialize-luôn kiểu cũ trên PG 12+ bằng cách viết
WITH cte_name AS MATERIALIZED (...), và ép inline ngay cả khi trái với suy đoán của planner bằngWITH cte_name AS NOT MATERIALIZED (...).
-- PostgreSQL 12+: MATERIALIZED ép một rào cản tối ưu hóa cứng, giống hệt hành vi mặc định trước phiên bản 12
WITH big_cte AS MATERIALIZED (
SELECT * FROM orders
)
SELECT * FROM big_cte WHERE customer_id = 42;
-- PostgreSQL 12+: NOT MATERIALIZED ép inline ngay cả khi planner muốn materialize
WITH filtered AS NOT MATERIALIZED (
SELECT * FROM orders WHERE amount > 100
)
SELECT * FROM filtered WHERE customer_id = 42;
MATERIALIZED vẫn thực sự hữu ích — ví dụ, khi một CTE tốn kém để tính toán và được tham chiếu nhiều lần trong query ngoài, ép materialize giúp tránh tính lại nó, hoặc khi CTE có các hàm không ổn định (volatile) (ví dụ random(), các hàm tiêu thụ sequence) mà kết quả cần phải giữ nguyên qua tất cả các lần tham chiếu. CTE đệ quy (bên dưới) luôn được materialize bất kể phiên bản nào, vì mô hình đánh giá đệ quy đòi hỏi như vậy.
Điểm cần nhớ cho bất kỳ ai đang audit hoặc viết query trên PostgreSQL production: nếu một query đang chạy trên PostgreSQL 11 trở về trước và sử dụng CTE nhiều kèm điều kiện lọc, hãy kiểm tra xem viết lại thành subquery (hoặc nâng cấp phiên bản) có cải thiện plan không, và luôn xác nhận hành vi thực tế bằng EXPLAIN (ANALYZE, BUFFERS) thay vì chỉ dựa vào phiên bản.
Recursive CTE
WITH RECURSIVE là cơ chế của PostgreSQL để duyệt dữ liệu phân cấp hoặc dạng đồ thị: sơ đồ tổ chức (org chart), cây danh mục (category tree), bảng kê vật tư (bill-of-materials), đồ thị phụ thuộc, và tìm đường đi. Một recursive CTE có hai phần, kết hợp bằng UNION hoặc UNION ALL:
- Base case (anchor member): một câu
SELECTkhông đệ quy, tạo ra (các) dòng khởi đầu. - Recursive case (recursive member): một câu
SELECTtham chiếu đến chính tên của CTE, join kết quả của lần lặp trước để tạo ra cấp tiếp theo. PostgreSQL chạy lặp lại phần này, đưa kết quả của mỗi lần lặp quay lại làm đầu vào, cho đến khi recursive member trả về 0 dòng.
Dưới đây là một ví dụ đầy đủ về việc duyệt sơ đồ phân cấp nhân viên-quản lý, tìm tất cả những người báo cáo, trực tiếp hoặc gián tiếp, cho một quản lý cho trước, cùng với độ sâu của họ trong sơ đồ tổ chức:
WITH RECURSIVE org_chart AS (
-- Base case: quản lý ta bắt đầu từ đó (độ sâu 0)
SELECT employee_id, name, manager_id, department, 0 AS depth,
name::text AS path
FROM employees
WHERE employee_id = 1 -- ví dụ: CEO
UNION ALL
-- Recursive case: tìm cấp dưới trực tiếp của những người đã có trong org_chart
SELECT e.employee_id, e.name, e.manager_id, e.department, oc.depth + 1,
oc.path || ' -> ' || e.name
FROM employees e
JOIN org_chart oc ON e.manager_id = oc.employee_id
)
SELECT employee_id, name, department, depth, path
FROM org_chart
ORDER BY depth, name;
Xét qua cách thực thi: PostgreSQL trước tiên chạy anchor member và đưa các dòng của nó vào một bảng làm việc (về mặt khái niệm, là nhân viên có id 1). Sau đó, nó chạy lặp lại recursive member, nhưng mỗi lần lặp chỉ “thấy” các dòng được thêm vào ở lần lặp trước đó dưới tên org_chart (không phải toàn bộ kết quả tích lũy) — vì vậy lượt đệ quy đầu tiên tìm cấp dưới trực tiếp của CEO, lượt thứ hai tìm cấp dưới trực tiếp của những người đó, cứ thế tiếp tục. Các dòng mới của mỗi lần lặp được thêm vào kết quả cuối cùng và đưa trở lại làm đầu vào, và quá trình tự động dừng lại khi một lần lặp không tạo ra dòng mới nào (ví dụ, khi đến những nhân viên không có cấp dưới trực tiếp).
Một vài lưu ý thực tế về recursive CTE:
- Dùng
UNION ALLthay vìUNIONtrừ khi bạn thực sự cần loại bỏ chu trình (cycle) bằng cách khử trùng lặp —UNIONsẽ khử trùng lặp thêm ở mỗi lần lặp, việc này tốn kém và chỉ đúng cho các trường hợp đơn giản. - Với dữ liệu có thể chứa chu trình (ví dụ, một đồ thị mà quan hệ “báo cáo cho” bị lỗi, hoặc duyệt đồ thị tổng quát), hãy phòng ngừa vòng lặp vô hạn bằng cách theo dõi các node đã thăm một cách tường minh:
WITH RECURSIVE org_chart AS (
SELECT employee_id, manager_id, ARRAY[employee_id] AS visited, 0 AS depth
FROM employees
WHERE employee_id = 1
UNION ALL
SELECT e.employee_id, e.manager_id, oc.visited || e.employee_id, oc.depth + 1
FROM employees e
JOIN org_chart oc ON e.manager_id = oc.employee_id
WHERE NOT e.employee_id = ANY(oc.visited) -- bảo vệ chống chu trình
)
SELECT * FROM org_chart;
- Recursive CTE cũng là cách chuẩn để làm “explosion” bảng kê vật tư (một sản phẩm gồm các cụm lắp con gồm các bộ phận) và làm phẳng cây danh mục (tìm tất cả danh mục con của một danh mục cho trước), theo đúng mẫu anchor + join đệ quy như trên.
LATERAL join
Một JOIN thông thường không thể có vế phải tham chiếu đến các cột của vế trái — mỗi vế của một join thông thường được đánh giá độc lập, và chỉ được kết hợp sau đó thông qua điều kiện join. LATERAL (ngầm định đối với hàm, tường minh qua từ khóa LATERAL đối với subquery) thay đổi điều này: nó cho phép subquery hoặc hàm ở vế phải tham chiếu đến các cột của các bảng xuất hiện trước đó trong cùng mệnh đề FROM, hiệu quả là chạy vế phải một lần cho mỗi dòng của vế trái, giống như một correlated subquery được nhúng trực tiếp trong mệnh đề FROM thay vì mệnh đề WHERE.
Ví dụ điển hình mà tính năng này mở khóa là “top N mỗi nhóm” — ví dụ, 3 đơn hàng gần nhất của mỗi khách hàng:
SELECT c.name, top_orders.order_id, top_orders.order_date, top_orders.amount
FROM customers c
CROSS JOIN LATERAL (
SELECT o.order_id, o.order_date, o.amount
FROM orders o
WHERE o.customer_id = c.customer_id
ORDER BY o.order_date DESC
LIMIT 3
) AS top_orders
ORDER BY c.name, top_orders.order_date DESC;
Chú ý rằng subquery tham chiếu c.customer_id — chính tham chiếu cột đó là điều làm cho query này là LATERAL; nếu không có từ khóa này, PostgreSQL sẽ từ chối query với lỗi vì một subquery thông thường trong FROM hoàn toàn không thể “thấy” c. LIMIT 3 bên trong lateral subquery là theo từng khách hàng, đó chính là mấu chốt.
Vì sao mẫu này khó diễn đạt nếu không có LATERAL? Một window function như ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) cũng có thể giải quyết “top 3 mỗi nhóm,” nhưng nó đòi hỏi tính row number trên toàn bộ bảng orders rồi lọc lại ở query ngoài (WHERE rn <= 3), vì window function không thể xuất hiện trực tiếp trong mệnh đề WHERE. Điều đó thường vẫn ổn, nhưng có nghĩa là phải quét và xếp hạng mọi dòng trong orders, kể cả những khách hàng bạn không quan tâm, và trở nên bất tiện khi logic “top N” cần thêm tính toán theo từng dòng (ví dụ, gọi một hàm trả về tập hợp — set-returning function, hoặc cần một LIMIT phụ thuộc vào một giá trị theo nhóm, như “top N với N được lưu trong cột customers.vip_tier”). Một join thông thường (không phải lateral) hoàn toàn không thể làm điều này — không có cách nào để nói “join mỗi khách hàng với một lát cắt đã lọc, sắp xếp, giới hạn của chính đơn hàng của họ” chỉ bằng JOIN ... ON, vì điều kiện ON chỉ liên kết các dòng khớp nhau, nó không thể kiểm soát thứ tự hay giới hạn số dòng bên trong join. LATERAL là công cụ trực tiếp, có thể kết hợp, cho “với mỗi dòng bên trái, tính một kết quả tương quan, có thể giới hạn, có thể trả về tập hợp ở bên phải,” bao gồm cả việc gọi hàm trả về bảng (table-returning function) cho từng dòng, điều mà window function hoàn toàn không thể làm.
Aggregate function so với window function
Cả aggregate function và window function đều tính toán những thứ như tổng, đếm, trung bình trên các tập dòng, nhưng chúng khác nhau căn bản ở chỗ chúng làm gì với hình dạng của tập kết quả.
Aggregate function dùng cùng GROUP BY sẽ gộp mỗi nhóm dòng đầu vào thành một dòng đầu ra duy nhất:
SELECT customer_id, COUNT(*) AS order_count, SUM(amount) AS total_spent
FROM orders
GROUP BY customer_id;
Nếu orders có một nghìn dòng trải trên năm mươi khách hàng, query này trả về đúng năm mươi dòng — mỗi khách hàng một dòng — và các dòng đơn hàng riêng lẻ biến mất khỏi kết quả.
Window function, dùng mệnh đề OVER (...), tính một giá trị giống aggregate trên một tập các dòng liên quan (gọi là “window”) nhưng trả về một dòng đầu ra cho mỗi dòng đầu vào, giữ nguyên tất cả các cột gốc:
SELECT order_id, customer_id, order_date, amount,
SUM(amount) OVER (PARTITION BY customer_id) AS customer_total,
COUNT(*) OVER (PARTITION BY customer_id) AS customer_order_count
FROM orders;
Nếu orders có một nghìn dòng, query này vẫn trả về một nghìn dòng — mỗi dòng đơn hàng giờ cũng mang theo tổng chi tiêu và số đơn hàng của khách hàng đó, được tính mà không gộp bất cứ thứ gì lại.
Cấu trúc của mệnh đề OVER
Mệnh đề window có ba phần tùy chọn:
PARTITION BYchia các dòng thành các nhóm (partition), tương tựGROUP BY, nhưng không gộp chúng lại. Window function của mỗi partition được tính độc lập.ORDER BY(bên trong mệnh đềOVER) định nghĩa thứ tự dùng cho các hàm nhạy cảm với thứ tự nhưROW_NUMBER,LAG/LEAD, và running total. Điều này độc lập với bất kỳORDER BYnào ở query ngoài.- Frame clause (
ROWS/RANGE BETWEEN ... AND ...) định nghĩa chính xác những dòng nào trong partition được tính, tương đối so với dòng hiện tại — ví dụ, “từ đầu partition đến dòng hiện tại,” chính là điều làm cho running total hoạt động.
Các window function thường dùng
ROW_NUMBER() gán một số nguyên tăng chặt, duy nhất trong mỗi partition, không có trường hợp bằng nhau:
SELECT employee_id, name, department, salary,
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS row_num
FROM employees;
RANK() và DENSE_RANK() xử lý các trường hợp bằng nhau khác nhau. RANK() cho các dòng bằng nhau cùng một hạng nhưng để lại khoảng trống sau đó (1, 2, 2, 4); DENSE_RANK() cho các dòng bằng nhau cùng một hạng không có khoảng trống (1, 2, 2, 3):
SELECT employee_id, name, department, salary,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rank_with_gaps,
DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rank_no_gaps
FROM employees;
LAG() và LEAD() nhìn vào một dòng trước hoặc sau dòng hiện tại trong window đã sắp xếp, mà không cần self-join:
SELECT customer_id, order_id, order_date, amount,
LAG(amount) OVER (PARTITION BY customer_id ORDER BY order_date) AS prev_order_amount,
LEAD(amount) OVER (PARTITION BY customer_id ORDER BY order_date) AS next_order_amount,
amount - LAG(amount) OVER (PARTITION BY customer_id ORDER BY order_date) AS change_from_prev
FROM orders;
Ví dụ thực hành: running total và rank trong từng nhóm
Ví dụ này kết hợp một running total (tổng lũy kế theo ngày, cho từng khách hàng) với một rank của nhân viên theo lương trong phòng ban của họ, trình bày cả hai mẫu cạnh nhau:
-- Tổng lũy kế số tiền đơn hàng theo từng khách hàng, sắp theo ngày
SELECT customer_id, order_id, order_date, amount,
SUM(amount) OVER (
PARTITION BY customer_id
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders
ORDER BY customer_id, order_date;
-- Xếp hạng lương của mỗi nhân viên trong phòng ban của họ
SELECT employee_id, name, department, salary,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS salary_rank
FROM employees
ORDER BY department, salary_rank;
Trong query đầu tiên, ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW định nghĩa tường minh frame là “mọi dòng từ đầu partition của khách hàng này cho đến và bao gồm dòng hiện tại” — thực ra đây chính là frame mặc định khi có ORDER BY mà không có mệnh đề frame tường minh, nhưng viết ra tường minh giúp ý định rõ ràng, không mơ hồ. Trong query thứ hai, RANK() trả lời câu hỏi “lương của nhân viên này xếp hạng thế nào so với đồng nghiệp cùng phòng ban,” một câu hỏi mà GROUP BY đơn thuần không thể trả lời, vì GROUP BY sẽ gộp mất các dòng nhân viên riêng lẻ.
Window function cũng có thể kết hợp với CTE để lọc trên giá trị đã tính, vì window function không thể xuất hiện trực tiếp trong WHERE:
WITH ranked_employees AS (
SELECT employee_id, name, department, salary,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS salary_rank
FROM employees
)
SELECT * FROM ranked_employees WHERE salary_rank <= 2; -- top 2 người lương cao nhất mỗi phòng ban
Bảng so sánh
CTE so với subquery so với LATERAL join
| Khía cạnh | CTE (WITH) | Subquery | LATERAL join |
|---|---|---|---|
| Khả năng đọc hiểu | Cao — các bước có tên, tuần tự | Thấp hơn — lồng nhau che khuất logic | Trung bình — tương quan tường minh, hiển thị trong FROM |
| Tái sử dụng trong cùng query | Có, tham chiếu tên CTE nhiều lần | Không, phải lặp lại văn bản subquery (hoặc planner có thể đánh giá lại nó) | Không, được đánh giá theo từng dòng bên trái, không tái sử dụng qua các dòng |
| Có thể tham chiếu cột của query ngoài (tương quan) | Không (CTE không đệ quy được đánh giá độc lập; CTE đệ quy tự tham chiếu chính nó, không phải query ngoài) | Có, nếu viết dưới dạng correlated subquery | Có — đây chính là đặc điểm định nghĩa của nó |
| Hành vi tối ưu hóa | PG 12+: inline mặc định như subquery trừ khi có MATERIALIZED; trước 12: luôn là rào cản | Thường được inline/gộp vào plan bởi planner | Đánh giá theo từng dòng ngoài; không phải rào cản, nhưng vốn tốn kém theo từng dòng |
| Trường hợp dùng điển hình | Chia một query phức tạp thành các bước logic có tên; duyệt đệ quy | Tra cứu giá trị đơn (scalar), kiểm tra tư cách thành viên IN/EXISTS | ”Top N mỗi nhóm,” gọi hàm trả về tập hợp cho từng dòng, LIMIT theo từng dòng |
Aggregate function so với window function
| Khía cạnh | Aggregate function (kèm GROUP BY) | Window function (OVER (...)) |
|---|---|---|
| Số dòng vào so với ra | Nhiều dòng vào, một dòng ra mỗi nhóm | Nhiều dòng vào, số dòng ra bằng nhau |
| Gộp mất các dòng chi tiết | Có | Không — các cột ở mức dòng gốc được giữ nguyên |
Có thể dùng trong WHERE | Không (phải dùng HAVING để lọc sau khi aggregate) | Không (phải bọc trong CTE/subquery rồi lọc ở query ngoài) |
| Trường hợp dùng điển hình | Tổng/tóm tắt theo từng nhóm (“tổng chi tiêu mỗi khách hàng”) | Running total, xếp hạng, so sánh với dòng lân cận, ngữ cảnh theo từng dòng cùng với dữ liệu chi tiết |
Best Practices
- Ưu tiên
UNION ALLthay vìUNIONbất cứ khi nào bạn biết (hoặc có thể đảm bảo qua các mệnh đềWHERE) rằng các tập kết quả không thể trùng lặp, hoặc khi trùng lặp là chấp nhận được — nó bỏ qua toàn bộ lượt khử trùng lặp. - Mặc định dùng
EXISTS/NOT EXISTScho các kiểm tra tồn tại tương quan, và không bao giờ dùngNOT INvới một subquery có thể trả vềNULL— dùngNOT EXISTSthay thế để tránh cạm bẫy “0 dòng” âm thầm. - Dùng CTE để đặt tên và sắp xếp trình tự các bước logic nhằm dễ đọc, nhưng trên PostgreSQL 11 trở về trước (hoặc khi bạn tường minh viết
MATERIALIZEDtrên 12+), nhớ rằng các điều kiện lọc không được đẩy vào bên trong CTE — kiểm traEXPLAINnếu một query dùng nhiều CTE chậm hơn dự kiến, và cân nhắc viết lại thành subquery hoặc dùngNOT MATERIALIZEDnếu bạn dùng 12+. - Luôn thêm cơ chế bảo vệ chống chu trình (mảng
visitedhoặc kiểm tra path) trong recursive CTE trên dữ liệu không được chứng minh là acyclic (không có chu trình), để tránh vòng lặp vô hạn. - Dùng
LATERALđặc biệt cho “top N mỗi nhóm” hoặc khi cần gọi một hàm trả về tập hợp cho từng dòng ngoài — nó thường rõ ràng hơn và có thể hiệu quả hơn cách dùng window function kèm lọc ở query ngoài khi N nhỏ, vì nó có thể dùngLIMITđể dừng sớm theo từng nhóm thay vì xếp hạng toàn bộ bảng. - Dùng window function thay vì self-join cho logic “so sánh với dòng trước/sau” (
LAG/LEAD) — chúng vừa rõ ràng hơn vừa thường nhanh hơn so với tự viết self-join. - Luôn kiểm chứng các giả định về chi phí query bằng
EXPLAIN (ANALYZE, BUFFERS)trên khối lượng dữ liệu đại diện thay vì dựa vào quy tắc chung — xem 10 — Query Planning and Performance Tuning để biết cách đọc plan cho các cấu trúc này (semi-join choEXISTS/IN,CTE Scanso với plan inline, nodeWindowAgg, và nested-loop plan choLATERAL).
Tài liệu tham khảo
- PostgreSQL Documentation — Combining Queries (UNION/INTERSECT/EXCEPT)
- PostgreSQL Documentation — Subqueries
- PostgreSQL Documentation — WITH Queries (Common Table Expressions)
- PostgreSQL Documentation — LATERAL Subqueries
- PostgreSQL Documentation — Window Functions
- PostgreSQL Documentation — Window Function Calls
- PostgreSQL 12 Release Notes — CTE inlining
- roadmap.sh — PostgreSQL DBA Roadmap
Part of the PostgreSQL DBA Roadmap knowledge base.
Overview
Basic SELECT ... WHERE ... JOIN statements take you a long way, but real-world reporting, analytics, and hierarchical data problems demand a richer toolbox. This note covers the constructs that separate someone who can “write a query” from someone who can express complex business logic declaratively and efficiently in SQL: set operations, subqueries, Common Table Expressions (CTEs), recursive CTEs, LATERAL joins, and window functions.
These features are not just syntactic sugar. Choosing the right tool has real performance consequences — an IN subquery and a semantically equivalent EXISTS subquery can produce very different query plans, and pre-12 PostgreSQL CTEs behaved as hard optimization barriers, which surprised many developers coming from other databases. Understanding these details lets you write queries that are both readable and fast, and it sets up the next topic in this series, query planning and performance tuning, where we look at how PostgreSQL executes these constructs under the hood.
This note assumes familiarity with basic SQL as covered in 04 — Querying Data / SQL Fundamentals. For how the planner turns these queries into execution plans (and how to read EXPLAIN output for them), see 10 — Query Planning and Performance Tuning.
Throughout this note we use a small sample schema:
CREATE TABLE customers (
customer_id serial PRIMARY KEY,
name text NOT NULL,
country text NOT NULL
);
CREATE TABLE orders (
order_id serial PRIMARY KEY,
customer_id int NOT NULL REFERENCES customers(customer_id),
order_date date NOT NULL,
amount numeric(10,2) NOT NULL
);
CREATE TABLE employees (
employee_id int PRIMARY KEY,
name text NOT NULL,
manager_id int REFERENCES employees(employee_id),
department text NOT NULL,
salary numeric(10,2) NOT NULL
);
Fundamentals
Set operations: UNION, INTERSECT, EXCEPT
Set operations combine the results of two or more SELECT statements that have the same number of columns with compatible types. PostgreSQL supports three set operators, each with a “distinct” (default) and an “all” variant.
UNION combines rows from two queries and removes duplicates by default:
SELECT name, country FROM customers WHERE country = 'Vietnam'
UNION
SELECT name, country FROM customers WHERE country = 'Singapore';
UNION ALL does the same thing but keeps duplicates:
SELECT name, country FROM customers WHERE country = 'Vietnam'
UNION ALL
SELECT name, country FROM customers WHERE country = 'Singapore';
The reason UNION ALL is faster than UNION is not a micro-optimization — it is a fundamentally different algorithm. UNION must sort or hash the combined result set to identify and eliminate duplicate rows, an extra full pass over the data (visible in EXPLAIN as a HashAggregate or Unique node on top of an Append). UNION ALL simply concatenates the row sets with an Append node and does no deduplication work at all. If you already know the two queries cannot produce overlapping rows (as in the example above, where country is mutually exclusive), always prefer UNION ALL — the correctness is identical, but you skip an expensive pass.
INTERSECT returns only rows that appear in both result sets:
-- Customers who ordered in 2023 AND 2024
SELECT customer_id FROM orders WHERE order_date >= '2023-01-01' AND order_date < '2024-01-01'
INTERSECT
SELECT customer_id FROM orders WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01';
This is useful for “in both groups” analysis — for example, finding customers who were active in two different periods, or products that appear in two different catalogs.
EXCEPT returns rows from the first query that do not appear in the second (set difference, order matters):
-- Customers who ordered in 2023 but did NOT order in 2024 (churned)
SELECT customer_id FROM orders WHERE order_date >= '2023-01-01' AND order_date < '2024-01-01'
EXCEPT
SELECT customer_id FROM orders WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01';
EXCEPT is the natural tool for “find what’s missing” or churn-style analysis: rows present in a reference set but absent from a comparison set. All three operators also have ALL variants (INTERSECT ALL, EXCEPT ALL) that use multiset semantics (matching duplicate counts) rather than deduplicating, though these are used far less often in practice.
Subqueries
A subquery is a SELECT nested inside another statement. Subqueries come in a few flavors that matter for both readability and performance.
A scalar subquery returns exactly one row and one column, and can be used anywhere a single value is expected:
SELECT name,
(SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.customer_id) AS order_count
FROM customers c;
If a scalar subquery unexpectedly returns more than one row at runtime, PostgreSQL raises an error (more than one row returned by a subquery used as an expression), so use them only when you can guarantee at most one matching row.
A subquery in the WHERE clause with IN checks membership against a set:
SELECT name FROM customers
WHERE customer_id IN (SELECT customer_id FROM orders WHERE amount > 1000);
This is an uncorrelated subquery — the inner query does not reference anything from the outer query, so PostgreSQL can, in principle, execute it once and reuse the result.
A correlated subquery references a column from the outer query inside the inner query, so it conceptually re-runs (or is at least logically evaluated) once per outer row:
-- Customers whose most recent order is above their own historical average
SELECT c.name
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
AND o.amount > (
SELECT AVG(o2.amount) FROM orders o2 WHERE o2.customer_id = c.customer_id
)
);
Here, the innermost subquery is correlated to c.customer_id from the outermost query, and the middle EXISTS subquery is also correlated to c.customer_id. Each is logically re-evaluated per candidate customer, though the planner may rewrite this into a join-like plan internally.
IN vs EXISTS vs NOT EXISTS
IN and EXISTS often look interchangeable, but they behave differently, especially at scale:
-- Using IN
SELECT name FROM customers c
WHERE c.customer_id IN (SELECT o.customer_id FROM orders o WHERE o.amount > 500);
-- Using EXISTS (correlated)
SELECT name FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.customer_id AND o.amount > 500
);
Both queries return the same result, but their execution strategies differ. IN conceptually needs the full list of matching values from the subquery before it can test membership; historically, and still in some plan shapes today, this meant materializing the entire subquery result (or de-duplicating it) before probing it for each outer row. EXISTS, on the other hand, is a existence check: PostgreSQL can stop scanning the inner query for a given outer row as soon as it finds one matching row (a “semi-join” with short-circuit behavior), rather than computing the complete inner result set.
In modern PostgreSQL (9.x+), the planner is often smart enough to rewrite an IN subquery into a semi-join plan that performs just as well as EXISTS, especially when the subquery involves a simple, indexed equality. But this rewrite is not guaranteed in every case — it depends on subquery shape, correlation, and whether the planner recognizes it as safely transformable. EXISTS gives you a stronger guarantee of short-circuiting behavior and tends to be more robust when:
- The subquery can return a very large or duplicate-heavy result set (avoiding a large intermediate materialization/dedup step that
INmay require). - The subquery is correlated and complex (aggregates, multiple joins) rather than a simple column reference.
- You need
NOT EXISTSinstead ofNOT IN— this distinction matters even more for correctness, not just performance: if the subquery inNOT INcan return anyNULL, the entireNOT INexpression evaluates toNULL(neither true nor false) for every outer row, silently returning zero rows.NOT EXISTShas no such trap:
-- Customers who have never placed an order
-- NOT EXISTS: safe, always correct
SELECT c.name FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id);
-- NOT IN: dangerous if orders.customer_id can ever be NULL
SELECT c.name FROM customers c
WHERE c.customer_id NOT IN (SELECT o.customer_id FROM orders o); -- returns 0 rows if any o.customer_id IS NULL
The practical rule of thumb: prefer EXISTS/NOT EXISTS for correlated existence checks and whenever NULL-safety matters, and don’t assume IN is automatically slower — always check with EXPLAIN ANALYZE on your actual data volumes (see 10 — Query Planning and Performance Tuning).
Key Concepts
Common Table Expressions (CTEs)
A CTE, introduced with the WITH clause, lets you give a name to a subquery and reference it later in the statement as if it were a table. The primary benefit is readability: instead of nesting nine levels of subqueries, you break a complex query into named, sequential logical steps.
WITH high_value_customers AS (
SELECT customer_id, SUM(amount) AS total_spent
FROM orders
GROUP BY customer_id
HAVING SUM(amount) > 5000
),
recent_orders AS (
SELECT customer_id, order_id, order_date, amount
FROM orders
WHERE order_date >= CURRENT_DATE - INTERVAL '90 days'
),
combined AS (
SELECT hvc.customer_id, hvc.total_spent, ro.order_id, ro.order_date, ro.amount
FROM high_value_customers hvc
JOIN recent_orders ro ON ro.customer_id = hvc.customer_id
)
SELECT c.name, combined.total_spent, combined.order_id, combined.order_date, combined.amount
FROM combined
JOIN customers c ON c.customer_id = combined.customer_id
ORDER BY combined.total_spent DESC, combined.order_date DESC;
Each WITH block reads like a step in a pipeline: first find high-value customers, then find recent orders, then join them together, then decorate with customer names. This is dramatically easier to review and modify than an equivalent query nested with nine levels of parentheses, and each named CTE can be tested independently by running it as a standalone SELECT while developing the query.
The PostgreSQL CTE optimization fence (materialization) — a PostgreSQL-specific gotcha
This is one of the most important PostgreSQL-specific details for anyone coming from another RDBMS, and it changed significantly between versions:
- PostgreSQL before version 12: every CTE in a
WITHclause was an optimization fence. The planner always materialized the CTE’s result (computed it fully, spilled to a temporary work area) before using it in the outer query, and it could not push filters or join conditions down into the CTE. This made CTEs a common performance trap — a query withWHEREconditions on a CTE column that could have been pushed down and used an index instead ran a full, unfiltered scan of the CTE first. - PostgreSQL 12 and later: non-recursive CTEs are, by default, inlined like a subquery or view whenever the planner determines it is safe and beneficial to do so — the fence is gone by default. The planner can push predicates in, choose to use indexes on the underlying tables, and merge the CTE into the surrounding query plan just as it would for an equivalent subquery.
- You can still force the old materialize-always behavior in PG 12+ by writing
WITH cte_name AS MATERIALIZED (...), and force inlining even against the planner’s own heuristic withWITH cte_name AS NOT MATERIALIZED (...).
-- PostgreSQL 12+: MATERIALIZED forces a hard optimization fence, exactly like pre-12 default behavior
WITH big_cte AS MATERIALIZED (
SELECT * FROM orders
)
SELECT * FROM big_cte WHERE customer_id = 42;
-- PostgreSQL 12+: NOT MATERIALIZED forces inlining even if the planner would rather materialize
WITH filtered AS NOT MATERIALIZED (
SELECT * FROM orders WHERE amount > 100
)
SELECT * FROM filtered WHERE customer_id = 42;
MATERIALIZED is still genuinely useful — for example, when a CTE is expensive to compute and referenced multiple times in the outer query, forcing materialization avoids recomputing it, or when the CTE has volatile functions (e.g., random(), sequence-consuming functions) whose results must be stable across all references. Recursive CTEs (below) are always materialized regardless of version, since the recursive evaluation model requires it.
The takeaway for anyone auditing or writing queries against production PostgreSQL: if a query is running on PostgreSQL 11 or earlier and uses CTEs heavily with filters, check whether rewriting as subqueries (or upgrading) improves the plan, and always confirm actual behavior with EXPLAIN (ANALYZE, BUFFERS) rather than assuming based on version alone.
Recursive CTEs
WITH RECURSIVE is PostgreSQL’s mechanism for traversing hierarchical or graph-like data: org charts, category trees, bill-of-materials explosions, dependency graphs, and pathfinding. A recursive CTE has two parts, combined with UNION or UNION ALL:
- The base case (anchor member): a non-recursive
SELECTthat produces the starting row(s). - The recursive case (recursive member): a
SELECTthat references the CTE’s own name, joining the previous iteration’s output to produce the next level. PostgreSQL runs this repeatedly, feeding each iteration’s result back in as input, until the recursive member returns zero rows.
Here is a full worked example traversing an employee-manager hierarchy to find everyone who reports, directly or indirectly, to a given manager, along with their depth in the org chart:
WITH RECURSIVE org_chart AS (
-- Base case: the manager we're starting from (depth 0)
SELECT employee_id, name, manager_id, department, 0 AS depth,
name::text AS path
FROM employees
WHERE employee_id = 1 -- e.g. the CEO
UNION ALL
-- Recursive case: find direct reports of everyone already in org_chart
SELECT e.employee_id, e.name, e.manager_id, e.department, oc.depth + 1,
oc.path || ' -> ' || e.name
FROM employees e
JOIN org_chart oc ON e.manager_id = oc.employee_id
)
SELECT employee_id, name, department, depth, path
FROM org_chart
ORDER BY depth, name;
Walking through the execution: PostgreSQL first runs the anchor member and puts its rows into a working table (conceptually, employee id 1). Then it repeatedly runs the recursive member, but each iteration only sees the rows added in the previous iteration as org_chart (not the full accumulated result) — so the first recursive pass finds direct reports of the CEO, the second pass finds direct reports of those people, and so on. Each iteration’s new rows are added to the final result and fed back in, and the process stops automatically once an iteration produces no new rows (e.g., once you reach employees with no direct reports).
A few practical notes on recursive CTEs:
- Use
UNION ALLrather thanUNIONunless you specifically need cycle-elimination via duplicate removal —UNIONwill additionally deduplicate on every iteration, which is expensive and only correct for simple cases. - For data that might contain cycles (e.g., a graph where a “reports to” arrangement is corrupted, or a general graph traversal), guard against infinite loops by tracking visited nodes explicitly:
WITH RECURSIVE org_chart AS (
SELECT employee_id, manager_id, ARRAY[employee_id] AS visited, 0 AS depth
FROM employees
WHERE employee_id = 1
UNION ALL
SELECT e.employee_id, e.manager_id, oc.visited || e.employee_id, oc.depth + 1
FROM employees e
JOIN org_chart oc ON e.manager_id = oc.employee_id
WHERE NOT e.employee_id = ANY(oc.visited) -- cycle guard
)
SELECT * FROM org_chart;
- Recursive CTEs are also the standard way to do bill-of-materials “explosion” (a product made of sub-assemblies made of parts) and category-tree flattening (find all descendant categories of a given category), following exactly the same anchor + recursive-join pattern shown above.
LATERAL joins
An ordinary JOIN cannot have its right-hand side reference columns from its left-hand side — each side of a plain join is evaluated independently, and only combined afterward via the join condition. LATERAL (implicit for functions, explicit via the LATERAL keyword for subqueries) changes this: it lets the subquery or function on the right-hand side reference columns from tables appearing earlier in the same FROM clause, effectively running the right-hand side once per row of the left-hand side, like a correlated subquery embedded directly in the FROM clause instead of the WHERE clause.
The canonical example this unlocks is “top N per group” — for instance, the 3 most recent orders for each customer:
SELECT c.name, top_orders.order_id, top_orders.order_date, top_orders.amount
FROM customers c
CROSS JOIN LATERAL (
SELECT o.order_id, o.order_date, o.amount
FROM orders o
WHERE o.customer_id = c.customer_id
ORDER BY o.order_date DESC
LIMIT 3
) AS top_orders
ORDER BY c.name, top_orders.order_date DESC;
Notice the subquery references c.customer_id — that column reference is exactly what makes this LATERAL; without the keyword, PostgreSQL would reject the query with an error because a plain subquery in FROM cannot see c at all. The LIMIT 3 inside the lateral subquery is per-customer, which is precisely the point.
Why is this pattern hard to express without LATERAL? A window function like ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) can also solve “top 3 per group,” but it requires computing the row number across the entire orders table and then filtering in an outer query (WHERE rn <= 3), because window functions cannot appear directly in a WHERE clause. That is often fine, but it means scanning and ranking every row in orders, even customers you might not care about, and it becomes awkward once the “top N” logic needs additional per-row computation (e.g., calling a set-returning function, or needing a LIMIT that itself depends on a per-group value, like “top N where N is stored in a customers.vip_tier column”). A plain (non-lateral) join simply cannot do this at all — there is no way to say “join each customer to a filtered, sorted, limited slice of their own orders” using JOIN ... ON alone, because the ON condition only relates matching rows, it cannot control ordering or limit within the join. LATERAL is the direct, composable tool for “for each row on the left, compute this correlated, possibly limited, possibly set-returning result on the right,” including calling table-returning functions per row, which window functions cannot do at all.
Aggregate functions vs. window functions
Both aggregate and window functions compute things like sums, counts, and averages over sets of rows, but they differ fundamentally in what they do to the result set’s shape.
An aggregate function used with GROUP BY collapses each group of input rows into a single output row:
SELECT customer_id, COUNT(*) AS order_count, SUM(amount) AS total_spent
FROM orders
GROUP BY customer_id;
If orders has a thousand rows across fifty customers, this query returns exactly fifty rows — one per customer — and the individual order rows are gone from the output.
A window function, using the OVER (...) clause, computes an aggregate-like value across a set of related rows (the “window”) but returns one output row per input row, keeping all original columns intact:
SELECT order_id, customer_id, order_date, amount,
SUM(amount) OVER (PARTITION BY customer_id) AS customer_total,
COUNT(*) OVER (PARTITION BY customer_id) AS customer_order_count
FROM orders;
If orders has a thousand rows, this query still returns a thousand rows — each order row now also carries its customer’s total spend and order count alongside it, computed without collapsing anything.
Anatomy of the OVER clause
The window clause has three optional parts:
PARTITION BYdivides rows into groups (partitions), analogous toGROUP BY, but without collapsing them. Each partition’s window function is computed independently.ORDER BY(within theOVERclause) defines the order used for order-sensitive functions likeROW_NUMBER,LAG/LEAD, and running totals. This is independent of anyORDER BYon the outer query.- Frame clause (
ROWS/RANGE BETWEEN ... AND ...) defines exactly which rows within the partition are included relative to the current row — for example, “from the start of the partition to the current row,” which is what makes running totals work.
Common window functions
ROW_NUMBER() assigns a strictly increasing, unique integer per partition, with no ties:
SELECT employee_id, name, department, salary,
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS row_num
FROM employees;
RANK() and DENSE_RANK() handle ties differently. RANK() gives tied rows the same rank but leaves a gap afterward (1, 2, 2, 4); DENSE_RANK() gives tied rows the same rank with no gap (1, 2, 2, 3):
SELECT employee_id, name, department, salary,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rank_with_gaps,
DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rank_no_gaps
FROM employees;
LAG() and LEAD() look at a row before or after the current row within the ordered window, without needing a self-join:
SELECT customer_id, order_id, order_date, amount,
LAG(amount) OVER (PARTITION BY customer_id ORDER BY order_date) AS prev_order_amount,
LEAD(amount) OVER (PARTITION BY customer_id ORDER BY order_date) AS next_order_amount,
amount - LAG(amount) OVER (PARTITION BY customer_id ORDER BY order_date) AS change_from_prev
FROM orders;
Worked example: running total and rank within groups
This example combines a running total (cumulative sum ordered by date, per customer) with a rank of employees by salary within their department, showing both patterns side by side:
-- Running total of order amount per customer, ordered by date
SELECT customer_id, order_id, order_date, amount,
SUM(amount) OVER (
PARTITION BY customer_id
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders
ORDER BY customer_id, order_date;
-- Rank of each employee's salary within their department
SELECT employee_id, name, department, salary,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS salary_rank
FROM employees
ORDER BY department, salary_rank;
In the first query, ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW explicitly defines the frame as “every row from the start of this customer’s partition up to and including the current row” — this is in fact the default frame when ORDER BY is present without an explicit frame clause, but writing it explicitly makes the intent unambiguous. In the second query, RANK() answers “where does this employee’s salary rank compared to peers in the same department,” which is a question GROUP BY alone cannot answer, since GROUP BY would collapse away the individual employee rows entirely.
Window functions can also be combined with a CTE to filter on the computed value, since window functions cannot appear directly in WHERE:
WITH ranked_employees AS (
SELECT employee_id, name, department, salary,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS salary_rank
FROM employees
)
SELECT * FROM ranked_employees WHERE salary_rank <= 2; -- top 2 earners per department
Comparison tables
CTE vs. subquery vs. LATERAL join
| Aspect | CTE (WITH) | Subquery | LATERAL join |
|---|---|---|---|
| Readability | High — named, sequential steps | Lower — nesting obscures logic | Medium — explicit correlation is visible in FROM |
| Reusable within the query | Yes, reference the CTE name multiple times | No, must repeat the subquery text (or the planner may re-evaluate it) | No, evaluated per left-hand row, not reused across rows |
| Can reference outer-query columns (correlation) | No (non-recursive CTEs are evaluated independently; recursive CTEs reference themselves, not an outer query) | Yes, if written as a correlated subquery | Yes — this is its defining feature |
| Optimization behavior | PG 12+: inlined by default like a subquery unless MATERIALIZED; pre-12: always a fence | Normally inlined/merged into the plan by the planner | Evaluated per outer row; not a fence, but inherently row-by-row in cost |
| Typical use case | Breaking a complex query into named logical steps; recursive traversal | Scalar lookups, IN/EXISTS membership tests | ”Top N per group,” calling a set-returning function per row, per-row LIMIT |
Aggregate function vs. window function
| Aspect | Aggregate function (with GROUP BY) | Window function (OVER (...)) |
|---|---|---|
| Rows in vs. rows out | Many rows in, one row out per group | Many rows in, same number of rows out |
| Collapses detail rows | Yes | No — original row-level columns are preserved |
Can be used in WHERE | No (must use HAVING for post-aggregation filters) | No (must wrap in a CTE/subquery and filter in the outer query) |
| Typical use case | Totals/summaries per group (“total spend per customer”) | Running totals, rankings, comparisons to neighboring rows, per-row context alongside detail data |
Best Practices
- Prefer
UNION ALLoverUNIONwhenever you know (or can guarantee via yourWHEREclauses) that the result sets cannot overlap, or when duplicates are acceptable — it skips an entire deduplication pass. - Default to
EXISTS/NOT EXISTSfor correlated existence checks, and never useNOT INwith a subquery that could returnNULL— useNOT EXISTSinstead to avoid the silent “zero rows” trap. - Use CTEs to name and sequence logical steps for readability, but on PostgreSQL 11 and earlier (or when you explicitly write
MATERIALIZEDon 12+), remember that filters do not push down into the CTE — checkEXPLAINif a CTE-heavy query is slower than expected, and consider rewriting as subqueries or usingNOT MATERIALIZEDif you’re on 12+. - Always include a cycle guard (a
visitedarray or path check) in recursive CTEs over data that isn’t provably acyclic, to avoid infinite loops. - Reach for
LATERALspecifically for “top N per group” or when you need to call a set-returning function per outer row — it is often clearer and can be more efficient than a window-function-plus-outer-filter approach when N is small, because it can useLIMITto stop early per group rather than ranking the entire table. - Use window functions instead of self-joins for “compare to previous/next row” logic (
LAG/LEAD) — they are both clearer and typically faster than a manual self-join. - Always verify assumptions about query cost with
EXPLAIN (ANALYZE, BUFFERS)on representative data volumes rather than relying on rules of thumb — see 10 — Query Planning and Performance Tuning for how to read plans involving these constructs (semi-joins forEXISTS/IN,CTE Scanvs. inlined plans,WindowAggnodes, and nested-loop plans forLATERAL).
References
- PostgreSQL Documentation — Combining Queries (UNION/INTERSECT/EXCEPT)
- PostgreSQL Documentation — Subqueries
- PostgreSQL Documentation — WITH Queries (Common Table Expressions)
- PostgreSQL Documentation — LATERAL Subqueries
- PostgreSQL Documentation — Window Functions
- PostgreSQL Documentation — Window Function Calls
- PostgreSQL 12 Release Notes — CTE inlining
- roadmap.sh — PostgreSQL DBA Roadmap