← PostgreSQL DBA← PostgreSQL DBA
PostgreSQL DBAPostgreSQL DBA19 Th7, 2026Jul 19, 202621 phút đọc18 min read

Truy vấn dữ liệu: SQL cơ bảnQuerying Data: SQL Fundamentals

Thuộc bộ kiến thức PostgreSQL DBA Roadmap.

Tổng quan

Trước khi một database administrator có thể tune một query, thiết kế index, hay lý giải một execution plan chậm, họ phải viết được query đó trước đã. Bài này là phần thực hành song song với lý thuyết relational đã trình bày ở ../backend/en/08-relational-databases.md — giả định bạn đã biết relation, primary key, và normalization là gì, và tập trung vào kỹ năng thực tế: viết câu SELECT trên một database PostgreSQL thật — filter rows, join tables, aggregate dữ liệu, và sắp xếp kết quả.

roadmap.sh coi “Learn SQL” là bước tiên quyết nằm trước khi lộ trình PostgreSQL DBA thực sự bắt đầu, và điều đó có ý nghĩa quan trọng: sự thành thạo SQL là nền tảng mà toàn bộ roadmap còn lại được xây dựng trên đó. Bạn không thể hiểu output của EXPLAIN, thiết kế một index hữu ích, hay viết một migration an toàn nếu không thể tự tin diễn đạt “cho tôi các row này, filter theo cách này, join với bảng kia, group như thế này.” Bài này chính là nền tảng đó, được cụ thể hóa bằng syntax của PostgreSQL và các bảng dữ liệu thật. Những chủ đề đi sâu hơn — window functions, CTEs, subqueries — được để dành cho ./05-advanced-querying.md; những chủ đề bàn về tại sao một query chậm thay vì cách viết nó được để dành cho ./10-query-planning-and-performance-tuning.md.

Xuyên suốt bài này ta dùng hai bảng ví dụ nhỏ để mọi thứ cụ thể:

CREATE TABLE customers (
    customer_id SERIAL PRIMARY KEY,
    full_name   TEXT NOT NULL,
    email       TEXT NOT NULL UNIQUE,
    country     TEXT NOT NULL,
    signed_up_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE orders (
    order_id    SERIAL PRIMARY KEY,
    customer_id INTEGER REFERENCES customers(customer_id),
    total_cents INTEGER NOT NULL,
    status      TEXT NOT NULL,       -- 'pending' | 'paid' | 'cancelled' | 'refunded'
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

INSERT INTO customers (full_name, email, country) VALUES
    ('Alice Nguyen',  'alice@example.com',  'VN'),
    ('Bao Tran',      'bao@example.com',    'VN'),
    ('Carlos Ruiz',   'carlos@example.com', 'MX'),
    ('Dana White',    'dana@example.com',   'US'),
    ('Emeka Obi',     'emeka@example.com',  'NG');

INSERT INTO orders (customer_id, total_cents, status, created_at) VALUES
    (1, 2500, 'paid',      now() - interval '10 days'),
    (1, 1200, 'paid',      now() - interval '5 days'),
    (2, 4300, 'pending',   now() - interval '2 days'),
    (3, 900,  'cancelled', now() - interval '20 days'),
    (3, 3000, 'paid',      now() - interval '1 days'),
    (4, 1500, 'refunded',  now() - interval '15 days');
    -- lưu ý: Emeka (customer_id 5) chưa đặt order nào

Kiến thức nền tảng

SELECT cơ bản

Câu query đơn giản nhất chọn ra các cột bạn muốn từ một bảng:

SELECT full_name, email FROM customers;

Dùng * một cách tiết chế (ổn cho việc khám phá dữ liệu ad-hoc, nhưng rủi ro trong application code — nó âm thầm trả về các cột mới thêm và phá vỡ các giả định về vị trí cột):

SELECT * FROM customers;

AS đặt alias cho một cột hoặc expression thành tên dễ đọc hơn. Từ khóa AS là tùy chọn nhưng giúp tăng khả năng đọc:

SELECT
    full_name AS customer_name,
    total_cents / 100.0 AS total_dollars
FROM customers c
JOIN orders o USING (customer_id);

DISTINCT loại bỏ các row trùng lặp trong kết quả — hữu ích khi trả lời “chúng ta có khách hàng ở những quốc gia nào?” thay vì “liệt kê quốc gia của từng khách hàng”:

SELECT DISTINCT country FROM customers;

DISTINCT ON (một mở rộng của PostgreSQL, không phải SQL chuẩn) giữ lại row đầu tiên trong mỗi group theo ORDER BY — thường dùng để lấy “order gần nhất của mỗi customer”:

SELECT DISTINCT ON (customer_id) customer_id, order_id, created_at
FROM orders
ORDER BY customer_id, created_at DESC;

LIMITOFFSET giới hạn số row trả về và điểm bắt đầu — công cụ cơ bản cho pagination:

SELECT order_id, total_cents, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;   -- "trang 3" của các trang 20 row

Tại sao pagination bằng OFFSET xuống cấp khi dữ liệu lớn. OFFSET 40 không nhảy thẳng đến row thứ 41 — PostgreSQL vẫn phải scan và bỏ qua 40 row khớp đầu tiên trước khi trả về 20 row tiếp theo. Khi offset càng lớn (trang 1000, OFFSET 20000), database phải làm ngày càng nhiều công việc bỏ đi cho mỗi request, và chi phí tăng gần như tuyến tính theo offset. Nó cũng không ổn định khi có ghi dữ liệu đồng thời: các row có thể dịch chuyển giữa các trang nếu dữ liệu được insert hoặc delete giữa các request. Cách khắc phục chuẩn là keyset pagination (còn gọi là “seek method”): thay vì bỏ qua N row, bạn nhớ sort key của row cuối cùng ở trang trước và filter các row nằm sau nó, ví dụ WHERE created_at < $last_seen_created_at ORDER BY created_at DESC LIMIT 20. Cách này cho phép index seek thẳng đến điểm bắt đầu đúng bất kể “trang” sâu đến đâu. Keyset pagination và các đánh đổi của nó (không thể “nhảy đến trang N” tùy ý, cần một sort key duy nhất, có index, đơn điệu) được trình bày sâu hơn ở ./10-query-planning-and-performance-tuning.md.

Filter dữ liệu với WHERE

WHERE giữ lại những row nào sống sót trước khi bất kỳ grouping hay selection nào xảy ra. PostgreSQL hỗ trợ các toán tử so sánh chuẩn cộng thêm vài toán tử tiện lợi.

-- So sánh cơ bản
SELECT * FROM orders WHERE total_cents > 2000;
SELECT * FROM orders WHERE status != 'cancelled';
SELECT * FROM orders WHERE created_at < now() - interval '7 days';

-- BETWEEN — bao gồm cả hai đầu
SELECT * FROM orders WHERE total_cents BETWEEN 1000 AND 3000;

-- IN — kiểm tra thành viên, gọn hơn chuỗi OR
SELECT * FROM orders WHERE status IN ('paid', 'refunded');

-- IS NULL / IS NOT NULL — không bao giờ dùng = NULL, nó luôn trả về NULL (unknown)
SELECT * FROM customers WHERE email IS NOT NULL;

-- LIKE / ILIKE — pattern matching; ILIKE là biến thể không phân biệt hoa thường của PostgreSQL
SELECT * FROM customers WHERE full_name LIKE 'A%';       -- bắt đầu bằng 'A', phân biệt hoa thường
SELECT * FROM customers WHERE full_name ILIKE 'a%';      -- bắt đầu bằng 'a' hoặc 'A'
SELECT * FROM customers WHERE email ILIKE '%@example.com';

Các wildcard của LIKE được thiết kế tối giản có chủ đích: % khớp một chuỗi ký tự bất kỳ (kể cả rỗng), _ khớp đúng một ký tự. Với những nhu cầu biểu đạt phức tạp hơn — alternation, anchor, character class, repetition — các toán tử POSIX regular expression của PostgreSQL đảm nhận:

-- ~   : khớp regex, phân biệt hoa thường
-- ~*  : khớp regex, không phân biệt hoa thường
-- !~  : không khớp regex, phân biệt hoa thường
-- !~* : không khớp regex, không phân biệt hoa thường

SELECT * FROM customers WHERE email ~ '^[a-z]+@example\.com$';
SELECT * FROM customers WHERE full_name ~* '^(alice|bao)';

Dùng LIKE/ILIKE cho các kiểm tra prefix/suffix/substring đơn giản; dùng ~/~* khi cần sức mạnh regex thực sự. Cả hai đều không tận dụng hiệu quả B-tree index thông thường cho tìm kiếm %pattern% — đó là chủ đề của indexing và full-text search, nằm ngoài phạm vi bài này.

Kết hợp các điều kiện. AND, OR, và NOT kết hợp các predicate, và PostgreSQL tuân theo thứ tự ưu tiên chuẩn của SQL: NOT liên kết chặt nhất, sau đó đến AND, rồi đến OR. Điều này liên tục gây nhầm lẫn — luôn dùng dấu ngoặc khi trộn ANDOR:

-- Gần như chắc chắn không phải điều mong muốn: NOT rồi AND rồi OR
-- được đọc là: (status = 'paid' AND country = 'VN') OR country = 'US'
SELECT * FROM customers c
JOIN orders o USING (customer_id)
WHERE status = 'paid' AND country = 'VN' OR country = 'US';

-- Điều có lẽ được mong muốn: các order đã paid của khách hàng ở VN hoặc US
SELECT * FROM customers c
JOIN orders o USING (customer_id)
WHERE status = 'paid' AND (country = 'VN' OR country = 'US');

Join các bảng

Join kết hợp các row từ hai hay nhiều bảng dựa trên một cột liên quan, phổ biến nhất là foreign key. PostgreSQL hỗ trợ đầy đủ bộ join chuẩn.

INNER JOIN chỉ trả về những row có match ở cả hai bên. Một khách hàng không có order nào (Emeka) biến mất hoàn toàn; một order không có customer khớp (không nên xảy ra vì có foreign key, nhưng về mặt khái niệm) cũng sẽ biến mất.

SELECT c.full_name, o.order_id, o.total_cents, o.status
FROM customers c
INNER JOIN orders o ON o.customer_id = c.customer_id;

Kết quả (về mặt khái niệm): mọi cặp (customer, order) mà điều kiện join thỏa mãn — 6 row, mỗi row ứng với một order. Emeka vắng mặt vì anh ấy không có order nào.

LEFT OUTER JOIN (LEFT JOIN) giữ lại mọi row từ bảng bên trái, điền NULL cho các cột bên phải khi không có match:

SELECT c.full_name, o.order_id, o.total_cents
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id;

Giờ Emeka xuất hiện một lần, với order_idtotal_cents đều là NULL — hữu ích cho các query kiểu “tìm khách hàng không có order nào” (WHERE o.order_id IS NULL).

RIGHT OUTER JOIN (RIGHT JOIN) là hình ảnh phản chiếu: giữ mọi row từ bảng bên phải, điền NULL bên trái. Trong thực tế nó ít được dùng vì bất kỳ RIGHT JOIN nào cũng có thể viết lại thành LEFT JOIN bằng cách đổi thứ tự bảng, và hầu hết style guide ưu tiên cách sau vì dễ đọc hơn.

FULL OUTER JOIN giữ mọi row từ cả hai bên, điền NULL cho bên nào không có match:

SELECT c.full_name, o.order_id
FROM customers c
FULL OUTER JOIN orders o ON o.customer_id = c.customer_id;

Nó trả về mọi thứ LEFT JOIN trả về, cộng thêm bất kỳ order nào (giả định) không có customer khớp.

Sơ đồ dạng văn bản về những row nào sống sót, với một khách hàng có order (Alice), một khách hàng không có order (Emeka), và một order mồ côi giả định:

                 INNER JOIN     LEFT JOIN      RIGHT JOIN     FULL OUTER JOIN
Alice + orders   kept            kept           kept            kept
Emeka, no orders dropped         kept (NULLs)   dropped         kept (NULLs)
orphan order     dropped         dropped        kept (NULLs)    kept (NULLs)

CROSS JOIN ghép mọi row bên trái với mọi row bên phải — một Cartesian product, không có điều kiện join nào cả:

SELECT c.full_name, s.status
FROM customers c
CROSS JOIN (VALUES ('pending'), ('paid'), ('cancelled'), ('refunded')) AS s(status);

Query đó là một cách dùng hợp lệ của CROSS JOIN (sinh ra mọi tổ hợp customer × status, ví dụ cho một report template). Trường hợp nguy hiểm là accidental cross-join: viết một FROM list phân tách bằng dấu phẩy, hoặc một INNER JOIN, và quên mất điều kiện ON/WHERE gắn kết các bảng lại.

-- BUG: không có điều kiện join — đây âm thầm là một cross join.
-- Với 5 customers và 6 orders, nó trả về 30 row, không phải 6.
SELECT c.full_name, o.order_id
FROM customers c, orders o;

-- Cùng lỗi, cú pháp khác — PostgreSQL sẽ chạy cái này mà không phàn nàn gì
SELECT c.full_name, o.order_id
FROM customers c JOIN orders o ON true;

Đây là một trong những lỗi SQL phổ biến nhất trong thực tế: số row âm thầm nhân lên, các aggregate bị đếm thừa, và không có gì báo lỗi cả — query chỉ đơn giản trả về con số sai. Cách phòng ngừa là kỷ luật: luôn viết cú pháp JOIN ... ON <condition> tường minh thay vì FROM list phân tách bằng dấu phẩy, và coi một số row bất ngờ lớn là tín hiệu để kiểm tra lại mọi điều kiện join.

Self-join join một bảng với chính nó, hữu ích cho các so sánh phân cấp hoặc tương đối — ví dụ, tìm các cặp khách hàng cùng quốc gia:

SELECT a.full_name AS customer_a, b.full_name AS customer_b, a.country
FROM customers a
JOIN customers b
    ON a.country = b.country
    AND a.customer_id < b.customer_id;   -- tránh khớp một row với chính nó hoặc trùng lặp cặp

Điều kiện a.customer_id < b.customer_id là mẹo chuẩn để tránh tự khớp (Alice, Alice) và tránh việc nhận cả (Alice, Bao) lẫn (Bao, Alice).

Grouping và aggregation

GROUP BY gộp các row có cùng giá trị ở (các) cột được chỉ định thành các row tổng hợp duy nhất, thường đi kèm với các hàm aggregate: COUNT, SUM, AVG, MIN, MAX.

SELECT
    c.country,
    COUNT(*)                AS order_count,
    SUM(o.total_cents)      AS total_revenue_cents,
    AVG(o.total_cents)      AS avg_order_cents,
    MIN(o.created_at)       AS first_order,
    MAX(o.created_at)       AS last_order
FROM customers c
JOIN orders o USING (customer_id)
GROUP BY c.country;

Mọi cột trong SELECT list phải hoặc xuất hiện trong GROUP BY, hoặc được bọc trong một hàm aggregate — PostgreSQL bắt buộc điều này và sẽ báo lỗi (column ... must appear in the GROUP BY clause or be used in an aggregate function) nếu vi phạm.

HAVING vs WHERE là một điểm gây nhầm lẫn phổ biến vì cả hai đều trông như filter, nhưng chúng hoạt động ở các giai đoạn logic khác nhau: WHERE filter từng row riêng lẻ trước khi grouping xảy ra, còn HAVING filter các group sau khi aggregation đã được tính. Điều này có nghĩa WHERE không thể tham chiếu một aggregate (WHERE COUNT(*) > 1 là bất hợp lệ), và HAVING thường được dùng chính xác để filter theo một aggregate.

-- WHERE loại bỏ row trước (chỉ order 'paid' được tính vào aggregate)
-- HAVING sau đó loại bỏ toàn bộ group (chỉ giữ quốc gia có >1 order đã paid)
SELECT
    c.country,
    COUNT(*)           AS paid_order_count,
    SUM(o.total_cents) AS paid_revenue_cents
FROM customers c
JOIN orders o USING (customer_id)
WHERE o.status = 'paid'
GROUP BY c.country
HAVING COUNT(*) > 1
ORDER BY paid_revenue_cents DESC;

Một cách nhớ nhanh giải quyết phần lớn sự nhầm lẫn: nếu điều kiện dựa trên giá trị cột thô của một row đơn lẻ (status = 'paid', total_cents > 500), nó thuộc về WHERE; nếu điều kiện dựa trên một aggregate được tính trên cả group (COUNT(*) > 1, SUM(total_cents) > 10000), nó thuộc về HAVING.

Toán tử

Ngoài so sánh, PostgreSQL hỗ trợ các toán tử số học thông thường (+, -, *, /, %) và nối chuỗi bằng ||:

SELECT full_name || ' <' || email || '>' AS contact_line FROM customers;
SELECT total_cents, total_cents / 100.0 AS dollars, total_cents * 1.1 AS with_10pct_tax FROM orders;

PostgreSQL cũng đi kèm các toán tử gắn với các kiểu dữ liệu phong phú của nó. Có hai điều đáng biết dù việc dùng sâu là một chủ đề chuyên biệt nằm ngoài phạm vi cốt lõi của roadmap này:

-- Array containment: @> nghĩa là "array bên trái chứa array bên phải"
SELECT ARRAY[1,2,3] @> ARRAY[2,3];   -- true

-- JSONB accessors: -> trả về JSONB, ->> trả về text, @> kiểm tra containment
SELECT '{"country": "VN", "tags": ["vip", "b2b"]}'::jsonb -> 'country';        -- "VN" (jsonb)
SELECT '{"country": "VN", "tags": ["vip", "b2b"]}'::jsonb ->> 'country';       -- VN (text)
SELECT '{"country": "VN"}'::jsonb @> '{"country": "VN"}'::jsonb;              -- true

Việc truy vấn JSONB đầy đủ, index hóa (GIN), và các array operator class xứng đáng có một bài riêng; ở đây chỉ cần nhận diện được syntax khi thấy nó.

Sắp xếp với ORDER BY

ORDER BY sắp xếp kết quả cuối cùng. Chiều mặc định là tăng dần (ASC); dùng DESC để giảm dần. Sắp xếp theo nhiều cột áp dụng mỗi cột như một tiebreaker cho các cột trước nó:

SELECT full_name, country, signed_up_at
FROM customers
ORDER BY country ASC, signed_up_at DESC;   -- nhóm theo country, trong mỗi nhóm đăng ký mới nhất lên đầu

Mặc định, PostgreSQL coi NULL lớn hơn mọi giá trị non-NULL khi sắp xếp ASC (nulls xếp cuối) và nhỏ hơn khi sắp xếp DESC (nulls xếp đầu) — nhưng hành vi mặc định này hiếm khi là điều bạn muốn phụ thuộc ngầm định vào, nên hãy nói rõ bằng NULLS FIRST / NULLS LAST:

SELECT order_id, status, created_at
FROM orders
ORDER BY created_at DESC NULLS LAST;

Ghép lại: một query tổng hợp có ví dụ cụ thể

Một query báo cáo thực tế chạm vào hầu hết các mệnh đề trên cùng lúc — tìm các quốc gia dẫn đầu về doanh thu đã paid trong số khách hàng đăng ký trong năm qua, chỉ hiển thị các quốc gia có ít nhất 2 order đã paid, quốc gia có doanh thu cao xếp trước:

SELECT
    c.country,
    COUNT(*)            AS paid_order_count,
    SUM(o.total_cents)   AS paid_revenue_cents
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
WHERE o.status = 'paid'
  AND c.signed_up_at > now() - interval '1 year'
GROUP BY c.country
HAVING COUNT(*) >= 2
ORDER BY paid_revenue_cents DESC
LIMIT 10;

Sẽ rất dễ đọc query này từ trên xuống dưới như thể đó là thứ tự thực thi của PostgreSQL, nhưng không phải vậy. SQL được viết theo một thứ tự và được xử lý về mặt logic theo một thứ tự khác — hiểu được thứ tự logic này là điều giúp HAVING so với WHERE, và tại sao bạn không thể filter theo một alias trong SELECTWHERE, trở nên hợp lý:

  1. FROM — xác định các bảng nguồn và tính toán các join, tạo ra một tập row kết hợp (customers join với orders).
  2. WHERE — filter từng row riêng lẻ từ tập kết hợp đó (chỉ giữ order status = 'paid' từ khách hàng đăng ký gần đây).
  3. GROUP BY — gộp các row còn lại thành các group có cùng country.
  4. HAVING — loại bỏ toàn bộ group không thỏa điều kiện aggregate (COUNT(*) >= 2).
  5. SELECT — tính các expression/aggregate đầu ra cho mỗi group còn sống sót.
  6. ORDER BY — sắp xếp các row cuối cùng.
  7. LIMIT / OFFSET — cắt kết quả xuống còn cửa sổ được yêu cầu.

Điều này giải thích hai điểm gây khó hiểu phổ biến: bạn không thể tham chiếu một alias được định nghĩa trong SELECT bên trong WHERE (vì WHERE chạy trước khi SELECT tính ra nó — dù PostgreSQL cho phép tham chiếu alias SELECT trong ORDER BYGROUP BY như một mở rộng tiện lợi), và bạn không thể filter theo một aggregate trong WHERE (vì aggregation chưa xảy ra ở giai đoạn đó).

Mệnh đề SQLThứ tự thực thi logicMục đích
FROM / JOIN1Xác định bảng nguồn, tính tập row đã join
WHERE2Filter từng row riêng lẻ trước khi grouping
GROUP BY3Gộp các row thành group có cùng (các) key
HAVING4Loại bỏ toàn bộ group dựa trên điều kiện aggregate
SELECT5Tính các cột/expression/aggregate đầu ra
DISTINCT6Loại bỏ row trùng lặp khỏi output đã tính
ORDER BY7Sắp xếp các row cuối cùng
LIMIT / OFFSET8Giới hạn xuống cửa sổ row được yêu cầu

Khái niệm chính

Kỷ luật chọn cột (projection)

Chỉ chọn những cột bạn cần (thay vì *) không chỉ là vấn đề style — nó giảm I/O khi liên quan đến các bảng rộng, tránh phá vỡ code phía sau khi cột mới được thêm sau này, và làm cho ý định của query rõ ràng khi review code.

Logic ba giá trị của NULL

NULL đại diện cho “không xác định,” không phải zero hay chuỗi rỗng, và nó lan truyền qua các so sánh: NULL = NULL trả về NULL (không phải true), và NULL trong WHERE được coi là “không khớp,” nên các row bị loại bỏ âm thầm thay vì báo lỗi. Đây là lý do IS NULL / IS NOT NULL tồn tại như các toán tử riêng, và tại sao NOT IN (subquery chứa NULL) là một cái bẫy kinh điển — nếu subquery trả về dù chỉ một NULL, toàn bộ NOT IN sẽ trả về unknown cho mọi row và query trả về rỗng. Ưu tiên NOT EXISTS hơn NOT IN khi vế phải có thể chứa NULL.

Tính đúng đắn của điều kiện join là mối quan tâm hàng đầu

Mỗi join nên được đọc bằng cách tự hỏi hai câu: những row nào từ mỗi bên chắc chắn sống sót, và điều gì xảy ra với các row không có match. INNER JOIN là loại join duy nhất có thể âm thầm bỏ mất dữ liệu hợp lệ (bất kỳ row nào thiếu match sẽ biến mất không dấu vết), đó chính xác là lý do “cái này có cần là LEFT JOIN không?” là một trong những câu hỏi đầu tiên đáng đặt ra khi một report dường như thiếu row.

Aggregate bỏ qua NULL (phần lớn)

COUNT(*) đếm mọi row bất kể NULL; COUNT(column) chỉ đếm các row mà column khác NULL; SUM, AVG, MIN, MAX đều bỏ qua hoàn toàn giá trị NULL thay vì coi nó là zero. Điều này quan trọng khi lý giải về tổng trên dữ liệu thưa — AVG(discount_cents) trên một bảng mà hầu hết order không có discount (NULL, không phải 0) tính ra trung bình của các discount đã áp dụng, không phải trung bình discount trên toàn bộ order.

Best Practices

Tài liệu tham khảo

Part of the PostgreSQL DBA Roadmap knowledge base.

Overview

Before a database administrator can tune a query, design an index, or reason about a slow execution plan, they have to be able to write the query in the first place. This topic is the hands-on counterpart to the relational theory covered in ../backend/en/08-relational-databases.md — it assumes you already know what a relation, a primary key, and normalization are, and instead focuses on the practical craft of writing SELECT statements against a real PostgreSQL database: filtering rows, joining tables, aggregating data, and ordering results.

roadmap.sh treats “Learn SQL” as a prerequisite step that sits before the PostgreSQL DBA path really begins, and that framing matters: SQL fluency is the baseline the entire rest of the roadmap builds on. You cannot understand EXPLAIN output, design a useful index, or write a safe migration if you cannot confidently express “give me these rows, filtered this way, joined to that table, grouped like this.” This note is that baseline, made concrete with PostgreSQL-flavored syntax and real tables. Where a topic goes deeper — window functions, CTEs, subqueries — it is deferred to ./05-advanced-querying.md; where a topic is about why a query is slow rather than how to write it, it is deferred to ./10-query-planning-and-performance-tuning.md.

Throughout this note we use two small example tables to keep everything concrete:

CREATE TABLE customers (
    customer_id SERIAL PRIMARY KEY,
    full_name   TEXT NOT NULL,
    email       TEXT NOT NULL UNIQUE,
    country     TEXT NOT NULL,
    signed_up_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE orders (
    order_id    SERIAL PRIMARY KEY,
    customer_id INTEGER REFERENCES customers(customer_id),
    total_cents INTEGER NOT NULL,
    status      TEXT NOT NULL,       -- 'pending' | 'paid' | 'cancelled' | 'refunded'
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

INSERT INTO customers (full_name, email, country) VALUES
    ('Alice Nguyen',  'alice@example.com',  'VN'),
    ('Bao Tran',      'bao@example.com',    'VN'),
    ('Carlos Ruiz',   'carlos@example.com', 'MX'),
    ('Dana White',    'dana@example.com',   'US'),
    ('Emeka Obi',     'emeka@example.com',  'NG');

INSERT INTO orders (customer_id, total_cents, status, created_at) VALUES
    (1, 2500, 'paid',      now() - interval '10 days'),
    (1, 1200, 'paid',      now() - interval '5 days'),
    (2, 4300, 'pending',   now() - interval '2 days'),
    (3, 900,  'cancelled', now() - interval '20 days'),
    (3, 3000, 'paid',      now() - interval '1 days'),
    (4, 1500, 'refunded',  now() - interval '15 days');
    -- note: Emeka (customer_id 5) has placed no orders yet

Fundamentals

SELECT basics

The simplest query names the columns you want from a table:

SELECT full_name, email FROM customers;

Use * sparingly (fine for ad-hoc exploration, risky in application code — it silently returns new columns and breaks positional assumptions):

SELECT * FROM customers;

AS aliases a column or expression to a friendlier name. The AS keyword itself is optional but improves readability:

SELECT
    full_name AS customer_name,
    total_cents / 100.0 AS total_dollars
FROM customers c
JOIN orders o USING (customer_id);

DISTINCT removes duplicate rows from the result set — useful for answering “which countries do we have customers in?” rather than “list every customer’s country”:

SELECT DISTINCT country FROM customers;

DISTINCT ON (a PostgreSQL extension, not standard SQL) keeps the first row per group according to ORDER BY — commonly used to fetch “the latest order per customer”:

SELECT DISTINCT ON (customer_id) customer_id, order_id, created_at
FROM orders
ORDER BY customer_id, created_at DESC;

LIMIT and OFFSET restrict how many rows come back and where the window starts — the bread and butter of pagination:

SELECT order_id, total_cents, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;   -- "page 3" of 20-row pages

Why OFFSET pagination degrades at scale. OFFSET 40 does not jump straight to row 41 — PostgreSQL must still scan and discard the first 40 matching rows before it can return the next 20. As the offset grows (page 1000, OFFSET 20000), the database does more and more throwaway work per request, and the cost grows roughly linearly with the offset. It is also unstable under concurrent writes: rows can shift between pages if data is inserted or deleted between requests. The standard fix is keyset pagination (a.k.a. “seek method”): instead of skipping N rows, you remember the last row’s sort key from the previous page and filter for rows strictly beyond it, e.g. WHERE created_at < $last_seen_created_at ORDER BY created_at DESC LIMIT 20. This lets the index seek directly to the right starting point regardless of how deep the “page” is. Keyset pagination and its trade-offs (no arbitrary “jump to page N”, requires a unique, indexed, monotonic sort key) are covered in more depth in ./10-query-planning-and-performance-tuning.md.

Filtering data with WHERE

WHERE restricts which rows survive before any grouping or selection happens. PostgreSQL supports the standard comparison operators plus a handful of convenience operators.

-- Basic comparisons
SELECT * FROM orders WHERE total_cents > 2000;
SELECT * FROM orders WHERE status != 'cancelled';
SELECT * FROM orders WHERE created_at < now() - interval '7 days';

-- BETWEEN — inclusive on both ends
SELECT * FROM orders WHERE total_cents BETWEEN 1000 AND 3000;

-- IN — membership test, cleaner than chained OR
SELECT * FROM orders WHERE status IN ('paid', 'refunded');

-- IS NULL / IS NOT NULL — never use = NULL, it always evaluates to NULL (unknown)
SELECT * FROM customers WHERE email IS NOT NULL;

-- LIKE / ILIKE — pattern matching; ILIKE is PostgreSQL's case-insensitive variant
SELECT * FROM customers WHERE full_name LIKE 'A%';       -- starts with 'A', case-sensitive
SELECT * FROM customers WHERE full_name ILIKE 'a%';      -- starts with 'a' or 'A'
SELECT * FROM customers WHERE email ILIKE '%@example.com';

LIKE wildcards are deliberately minimal: % matches any sequence of characters (including none), _ matches exactly one character. For anything more expressive — alternation, anchors, character classes, repetition — PostgreSQL’s POSIX regular expression operators take over:

-- ~   : matches regex, case-sensitive
-- ~*  : matches regex, case-insensitive
-- !~  : does not match regex, case-sensitive
-- !~* : does not match regex, case-insensitive

SELECT * FROM customers WHERE email ~ '^[a-z]+@example\.com$';
SELECT * FROM customers WHERE full_name ~* '^(alice|bao)';

Reach for LIKE/ILIKE for simple prefix/suffix/substring checks; reach for ~/~* when you need real regex power. Neither uses a plain B-tree index efficiently for %pattern% searches — that is a topic for indexing and full-text search, out of scope here.

Combining conditions. AND, OR, and NOT combine predicates, and PostgreSQL follows standard SQL precedence: NOT binds tightest, then AND, then OR. This trips people up constantly — always parenthesize when mixing AND and OR:

-- Almost certainly not what was intended: NOT then AND then OR
-- reads as: (status = 'paid' AND country = 'VN') OR country = 'US'
SELECT * FROM customers c
JOIN orders o USING (customer_id)
WHERE status = 'paid' AND country = 'VN' OR country = 'US';

-- What was probably intended: paid orders from customers in VN or US
SELECT * FROM customers c
JOIN orders o USING (customer_id)
WHERE status = 'paid' AND (country = 'VN' OR country = 'US');

Joining tables

Joins combine rows from two or more tables based on a related column, most commonly a foreign key. PostgreSQL supports the full standard set.

INNER JOIN returns only rows that have a match on both sides. A customer with no orders (Emeka) disappears entirely; an order with no matching customer (shouldn’t happen given the foreign key, but conceptually) would also disappear.

SELECT c.full_name, o.order_id, o.total_cents, o.status
FROM customers c
INNER JOIN orders o ON o.customer_id = c.customer_id;

Result (conceptually): every (customer, order) pair where the join condition holds — 6 rows, one per order. Emeka is absent because he has zero orders.

LEFT OUTER JOIN (LEFT JOIN) keeps every row from the left table, filling in NULL for right-side columns when there is no match:

SELECT c.full_name, o.order_id, o.total_cents
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id;

Now Emeka appears once, with order_id and total_cents both NULL — useful for “find customers with no orders” queries (WHERE o.order_id IS NULL).

RIGHT OUTER JOIN (RIGHT JOIN) is the mirror image: keep every row from the right table, NULL-pad the left side. It is rarely used in practice because any RIGHT JOIN can be rewritten as a LEFT JOIN by swapping table order, and most style guides prefer the latter for readability.

FULL OUTER JOIN keeps every row from both sides, NULL-padding whichever side has no match:

SELECT c.full_name, o.order_id
FROM customers c
FULL OUTER JOIN orders o ON o.customer_id = c.customer_id;

This returns everything LEFT JOIN returns, plus any order rows that (hypothetically) had no matching customer.

A text diagram of which rows survive, for a customer with orders (Alice), a customer without orders (Emeka), and a hypothetical orphan order:

                 INNER JOIN     LEFT JOIN      RIGHT JOIN     FULL OUTER JOIN
Alice + orders   kept            kept           kept            kept
Emeka, no orders dropped         kept (NULLs)   dropped         kept (NULLs)
orphan order     dropped         dropped        kept (NULLs)    kept (NULLs)

CROSS JOIN pairs every row on the left with every row on the right — a Cartesian product, with no join condition at all:

SELECT c.full_name, s.status
FROM customers c
CROSS JOIN (VALUES ('pending'), ('paid'), ('cancelled'), ('refunded')) AS s(status);

That query is a legitimate use of CROSS JOIN (generating every customer × status combination, e.g. for a report template). The dangerous case is the accidental cross-join: writing a comma-separated FROM list, or an INNER JOIN, and forgetting the ON/WHERE condition that ties the tables together.

-- BUG: no join condition — this is silently a cross join.
-- With 5 customers and 6 orders, it returns 30 rows, not 6.
SELECT c.full_name, o.order_id
FROM customers c, orders o;

-- Same bug, different syntax — PostgreSQL will run this without complaint
SELECT c.full_name, o.order_id
FROM customers c JOIN orders o ON true;

This is one of the most common real-world SQL bugs: row counts silently multiply, aggregates over-count, and nothing errors — the query just returns wrong numbers. The defense is discipline: always write explicit JOIN ... ON <condition> syntax rather than comma-separated FROM lists, and treat an unexpectedly large row count as a signal to check every join condition.

Self-joins join a table to itself, useful for hierarchical or relative comparisons — e.g., finding pairs of customers from the same country:

SELECT a.full_name AS customer_a, b.full_name AS customer_b, a.country
FROM customers a
JOIN customers b
    ON a.country = b.country
    AND a.customer_id < b.customer_id;   -- avoid matching a row with itself or duplicate pairs

The a.customer_id < b.customer_id condition is the standard trick to avoid (Alice, Alice) self-matches and to avoid getting both (Alice, Bao) and (Bao, Alice).

Grouping and aggregation

GROUP BY collapses rows sharing the same value(s) in specified column(s) into single summary rows, typically paired with aggregate functions: COUNT, SUM, AVG, MIN, MAX.

SELECT
    c.country,
    COUNT(*)                AS order_count,
    SUM(o.total_cents)      AS total_revenue_cents,
    AVG(o.total_cents)      AS avg_order_cents,
    MIN(o.created_at)       AS first_order,
    MAX(o.created_at)       AS last_order
FROM customers c
JOIN orders o USING (customer_id)
GROUP BY c.country;

Every column in the SELECT list must either appear in GROUP BY or be wrapped in an aggregate function — PostgreSQL enforces this and will raise an error (column ... must appear in the GROUP BY clause or be used in an aggregate function) otherwise.

HAVING vs WHERE is a frequent point of confusion because both look like filters, but they operate at different logical stages: WHERE filters individual rows before grouping happens, while HAVING filters groups after aggregation has been computed. This means WHERE cannot reference an aggregate (WHERE COUNT(*) > 1 is illegal), and HAVING is typically used precisely to filter on an aggregate.

-- WHERE removes rows first (only paid orders count toward the aggregates)
-- HAVING then removes whole groups (only countries with >1 paid order)
SELECT
    c.country,
    COUNT(*)           AS paid_order_count,
    SUM(o.total_cents) AS paid_revenue_cents
FROM customers c
JOIN orders o USING (customer_id)
WHERE o.status = 'paid'
GROUP BY c.country
HAVING COUNT(*) > 1
ORDER BY paid_revenue_cents DESC;

A mental shortcut that resolves most of the confusion: if the condition is about a single row’s raw column value (status = 'paid', total_cents > 500), it belongs in WHERE; if the condition is about a computed aggregate over a group (COUNT(*) > 1, SUM(total_cents) > 10000), it belongs in HAVING.

Operators

Beyond comparisons, PostgreSQL supports the usual arithmetic operators (+, -, *, /, %) and string concatenation with ||:

SELECT full_name || ' <' || email || '>' AS contact_line FROM customers;
SELECT total_cents, total_cents / 100.0 AS dollars, total_cents * 1.1 AS with_10pct_tax FROM orders;

PostgreSQL also ships operators tied to its richer types. Two worth knowing exist, even though deep usage is a specialized topic beyond this roadmap’s core scope:

-- Array containment: @> means "left array contains right array"
SELECT ARRAY[1,2,3] @> ARRAY[2,3];   -- true

-- JSONB accessors: -> returns JSONB, ->> returns text, @> tests containment
SELECT '{"country": "VN", "tags": ["vip", "b2b"]}'::jsonb -> 'country';        -- "VN" (jsonb)
SELECT '{"country": "VN", "tags": ["vip", "b2b"]}'::jsonb ->> 'country';       -- VN (text)
SELECT '{"country": "VN"}'::jsonb @> '{"country": "VN"}'::jsonb;              -- true

Full JSONB querying, indexing (GIN), and array operator classes deserve their own dedicated note; here it is enough to recognize the syntax when you see it.

Sorting with ORDER BY

ORDER BY sorts the final result set. Default direction is ascending (ASC); use DESC for descending. Sorting by multiple columns applies each as a tiebreaker for the ones before it:

SELECT full_name, country, signed_up_at
FROM customers
ORDER BY country ASC, signed_up_at DESC;   -- group by country, newest signups first within each

PostgreSQL treats NULL as larger than any non-NULL value by default when sorting ASC (nulls come last) and smaller when sorting DESC (nulls come first) — but this default is rarely what you want to rely on implicitly, so state it explicitly with NULLS FIRST / NULLS LAST:

SELECT order_id, status, created_at
FROM orders
ORDER BY created_at DESC NULLS LAST;

Putting it together: a worked composite query

A realistic reporting query touches most of the clauses above at once — find the top countries by paid revenue among customers who signed up in the last year, showing only countries with at least 2 paid orders, newest-heavy countries first:

SELECT
    c.country,
    COUNT(*)            AS paid_order_count,
    SUM(o.total_cents)   AS paid_revenue_cents
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
WHERE o.status = 'paid'
  AND c.signed_up_at > now() - interval '1 year'
GROUP BY c.country
HAVING COUNT(*) >= 2
ORDER BY paid_revenue_cents DESC
LIMIT 10;

It is tempting to read this top-to-bottom as PostgreSQL’s execution order, but that is not how it works. SQL is written in one order and logically processed in another — understanding the logical order is what makes HAVING vs WHERE, and why you can’t filter on a SELECT alias in WHERE, make sense:

  1. FROM — identify the source tables and evaluate joins, producing a combined row set (customers joined to orders).
  2. WHERE — filter individual rows from that combined set (keep only status = 'paid' orders from recently-signed-up customers).
  3. GROUP BY — collapse the remaining rows into groups sharing the same country.
  4. HAVING — filter out entire groups that don’t meet the aggregate condition (COUNT(*) >= 2).
  5. SELECT — compute the output expressions/aggregates for each surviving group.
  6. ORDER BY — sort the final rows.
  7. LIMIT / OFFSET — cut the result down to the requested window.

This explains two common gotchas: you cannot reference a SELECT-defined alias inside WHERE (because WHERE runs before SELECT computes it — though PostgreSQL does permit referencing SELECT aliases in ORDER BY and GROUP BY as a convenience extension), and you cannot filter on an aggregate in WHERE (because aggregation hasn’t happened yet at that stage).

SQL clauseLogical execution orderPurpose
FROM / JOIN1Identify source tables, compute the joined row set
WHERE2Filter individual rows before any grouping
GROUP BY3Collapse rows into groups sharing the same key(s)
HAVING4Filter out whole groups based on aggregate conditions
SELECT5Compute output columns/expressions/aggregates
DISTINCT6Remove duplicate rows from the computed output
ORDER BY7Sort the final rows
LIMIT / OFFSET8Restrict to the requested window of rows

Key Concepts

Column selection and projection discipline

Selecting only the columns you need (rather than *) is not just style — it reduces I/O when wide tables are involved, avoids breaking downstream code when columns are added later, and makes query intent explicit in code review.

The three-valued logic of NULL

NULL represents “unknown,” not zero or empty string, and it propagates through comparisons: NULL = NULL evaluates to NULL (not true), and NULL in WHERE is treated as “not a match,” so rows are silently excluded rather than raising an error. This is why IS NULL / IS NOT NULL exist as dedicated operators, and why NOT IN (subquery containing a NULL) is a classic footgun — if the subquery returns even one NULL, the entire NOT IN evaluates to unknown for every row and the query returns nothing. Prefer NOT EXISTS over NOT IN when the right-hand side might contain NULLs.

Join condition correctness as a first-class concern

Every join should be read by asking two questions: which rows from each side are guaranteed to survive, and what happens to rows with no match. INNER JOIN is the only join type that can silently drop legitimate data (any row lacking a match vanishes without a trace), which is exactly why “does this need to be a LEFT JOIN?” is one of the first questions worth asking when a report seems to be missing rows.

Aggregates ignore NULL (mostly)

COUNT(*) counts rows regardless of NULLs; COUNT(column) counts only rows where column is non-NULL; SUM, AVG, MIN, MAX all skip NULL values entirely rather than treating them as zero. This matters when reasoning about totals over sparse data — AVG(discount_cents) over a table where most orders have no discount (NULL, not 0) computes the average of applied discounts, not the average discount across all orders.

Best Practices

References