SQL & Database — Thực chiến PostgreSQL/Window functions intro — OVER + PARTITION BY giữ row + tính cross-row
~22 phútJoin, aggregation & window lượt xem

Window functions intro — OVER + PARTITION BY giữ row + tính cross-row

Window = cửa sổ nhìn nhóm row liên quan, vẫn đứng tại 1 row. Khác GROUP BY collapse. 4 use case: ranking, aggregate over window, lag/lead, frame. WHERE rn=1 pitfall.

TaskFlow cần báo cáo: "task nào có due_at gần nhất trong từng project?" Câu truy vấn đầu tiên bạn thử dùng GROUP BY project_id — gom 30 task về 1 row per project, mất toàn bộ thông tin từng task. Bạn không thể giữ id, title, assignee_id vì chúng không nằm trong GROUP BY và không được aggregate.

Window function giải quyết bài toán này: giữ nguyên 30 row, thêm column rn tính từ thứ tự due_at trong từng project. Filter rn = 1 cho bạn đúng task muốn mà không mất chi tiết. Đây là pattern xuất hiện trong 79% job description analytics SQL — ranking per group, aggregate kết hợp so sánh cross-row, lag/lead giữa kỳ.

Bài này map khái niệm cửa sổ, cú pháp OVER, 4 use case cơ bản, và 1 pitfall mà hầu hết dev mới đều dính lần đầu.

1. Analogy — Cửa sổ nhìn xung quanh

Hãy tưởng tượng một bữa tiệc công ty: mọi người ngồi theo bàn (project). GROUP BY giống ban tổ chức gom mọi người thành từng bàn rồi chỉ báo cáo tổng số người mỗi bàn — bạn mất danh sách từng người. Window function giống mỗi người vẫn ngồi tại chỗ của mình, nhìn quanh bàn để biết mình đứng thứ mấy trong nhóm — identity vẫn giữ nguyên, thêm thông tin cross-row.

Bữa tiệcSQL
Ban tổ chức gom bàn, báo tổng số ngườiGROUP BY — collapse nhiều row thành 1
Mỗi người ngồi tại chỗ, nhìn quanh bànWindow function — giữ row, thêm cross-row metric
"Bàn A" thay vì từng tênAggregate result — mất individual identity
Rank trong bàn của mìnhROW_NUMBER() OVER (PARTITION BY project_id ORDER BY due_at)
💡 Cách nhớ

Window = "cửa sổ nhìn nhóm" — bạn vẫn đứng tại row của mình, nhưng thấy được thông tin của cả nhóm xung quanh. GROUP BY collapse nhóm thành 1 row. Window không collapse — mỗi row vẫn xuất hiện trong kết quả.

2. Cú pháp + cơ chế

SELECT
  id,
  project_id,
  title,
  due_at,
  ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY due_at) AS rn
FROM tasks;

Ba phần trong OVER(...):

PhầnVai tròGhi chú
PARTITION BY project_idChia rows thành group theo projectGiống GROUP BY nhưng KHÔNG collapse
ORDER BY due_atThứ tự trong từng windowQuyết định rank, lag/lead, frame
(frame clause)Phạm vi rows tính trong windowMặc định hoặc chỉ định — bài 7 deep dive

Output minh họa:

id | project_id | title       | due_at     | rn
---+------------+-------------+------------+----
 1 |          1 | Deploy v1   | 2026-05-01 |  1
 2 |          1 | Fix auth    | 2026-05-03 |  2
 3 |          1 | Update docs | 2026-05-10 |  3
 4 |          2 | Mockup home | 2026-05-02 |  1
 5 |          2 | Logo design | 2026-05-05 |  2

Mỗi row vẫn xuất hiện đầy đủ. rn reset cho mỗi project_idPARTITION BY project_id. Task id=4 của project 2 có rn=1 độc lập với project 1.

3. So sánh window vs aggregate vs subquery

-- AGGREGATE: collapse, mat row chi tiet
SELECT project_id, AVG(EXTRACT(DAY FROM (updated_at - created_at)))
FROM tasks
GROUP BY project_id;
-- Output: 1 row per project, khong xem duoc task individual
-- WINDOW: giu row + add column cross-row
SELECT
  id,
  title,
  project_id,
  EXTRACT(DAY FROM (updated_at - created_at))              AS my_days,
  AVG(EXTRACT(DAY FROM (updated_at - created_at)))
    OVER (PARTITION BY project_id)                         AS project_avg
FROM tasks;
-- Output: moi row task + column project_avg tinh tren ca project
-- SUBQUERY: verbose hon, plan thuong cham hon voi bang lon
SELECT
  t.id,
  t.title,
  t.project_id,
  EXTRACT(DAY FROM (t.updated_at - t.created_at))          AS my_days,
  (SELECT AVG(EXTRACT(DAY FROM (updated_at - created_at)))
   FROM tasks
   WHERE project_id = t.project_id)                        AS project_avg
FROM tasks t;
-- Output: same result nhung correlated subquery chay lai cho moi row

Window là lựa chọn tốt nhất cho pattern "giữ row chi tiết + thêm cross-row metric". Subquery correlated đạt cùng kết quả nhưng thường chậm hơn với bảng lớn vì chạy lại cho mỗi row.

4. 4 use case — tổng quan

#PatternFunction ví dụUse case
1RankingROW_NUMBER, RANK, DENSE_RANKTop N per group, leaderboard
2Aggregate over windowSUM/AVG/COUNT OVERRow detail + group metric song song
3Lag/LeadLAG, LEADSo sánh với row trước/sau
4Frame (running/moving)SUM OVER ROWS BETWEENRunning total, moving average

Bài tiếp theo (Module 3 bài 7 của khoá này) đi sâu cả 4 pattern với ví dụ thực chiến. Bài này focus intro + pattern 1 (ranking) + pattern 2 (aggregate over window) để xây nền.

5. Pattern 1 — Ranking với ROW_NUMBER

-- "Task co due_at som nhat per project"
SELECT
  id,
  project_id,
  title,
  due_at,
  ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY due_at) AS rn
FROM tasks;
-- rn=1 la task due som nhat trong project do

Filter rn = 1 cho top-1 per project. Nhưng WHERE rn = 1 trực tiếp báo lỗi — đây là pitfall quan trọng, xem section 6.

Ba hàm ranking hay dùng:

HàmTie handlingVí dụ với tie
ROW_NUMBER()Luôn unique, tie break tùy ý1, 2, 3, 4
RANK()Gap sau tie1, 2, 2, 4
DENSE_RANK()Không gap sau tie1, 2, 2, 3

6. Pitfall — WHERE rn = 1 không hợp lệ

Pitfall — WHERE không thấy window function result

Window function nằm trong SELECT (bước 5 của logical order). WHERE chạy ở bước 2 — trước khi window được tính. Dùng WHERE rn = 1 sau ROW_NUMBER() OVER (...) AS rn sẽ báo column "rn" does not exist.

-- BUG: WHERE chay TRUOC SELECT (trong do chua window result)
SELECT
  id,
  project_id,
  title,
  ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY due_at) AS rn
FROM tasks
WHERE rn = 1;
-- ERROR: column "rn" does not exist

Ba cách fix:

-- Fix 1: wrap subquery -- universal, moi dialect
SELECT * FROM (
  SELECT
    id,
    project_id,
    title,
    ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY due_at) AS rn
  FROM tasks
) sub
WHERE rn = 1;
-- Fix 2: CTE (Module 8 cua khoa nay -- readable hon cho query phuc tap)
WITH ranked AS (
  SELECT
    id,
    project_id,
    title,
    ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY due_at) AS rn
  FROM tasks
)
SELECT * FROM ranked WHERE rn = 1;
-- Fix 3: DISTINCT ON (PostgreSQL-only) -- ngan nhat cho top-1 per group
SELECT DISTINCT ON (project_id)
  id, project_id, title, due_at
FROM tasks
ORDER BY project_id, due_at;

DISTINCT ON là lựa chọn ngắn gọn nhất cho "top-1 per group" trong PostgreSQL. Window function cần thiết khi muốn top-N (N vượt 1) hoặc khi cần giữ rank value trong kết quả để downstream logic dùng.

7. Pattern 2 — Aggregate over window

-- Per task: so ngay hoan thanh cua task nay + trung binh cua project + tong cua assignee
SELECT
  id,
  title,
  EXTRACT(DAY FROM (updated_at - created_at))              AS my_days,
  AVG(EXTRACT(DAY FROM (updated_at - created_at)))
    OVER (PARTITION BY project_id)                         AS project_avg,
  SUM(EXTRACT(DAY FROM (updated_at - created_at)))
    OVER (PARTITION BY assignee_id)                        AS my_total_days
FROM tasks
WHERE status = 'done';

Hai window khác nhau (PARTITION BY project_idPARTITION BY assignee_id) trong cùng một query hoàn toàn hợp lệ. PostgreSQL tính từng window function độc lập — mỗi OVER(...) định nghĩa một cửa sổ riêng.

8. Applied — TaskFlow leaderboard nâng cao

-- "Per user: so task done + rank trong project theo so task done"
SELECT
  u.id,
  u.name,
  t.project_id,
  COUNT(t.id) FILTER (WHERE t.status = 'done')           AS done_count,
  RANK() OVER (
    PARTITION BY t.project_id
    ORDER BY COUNT(t.id) FILTER (WHERE t.status = 'done') DESC
  )                                                        AS project_rank
FROM users u
LEFT JOIN tasks t ON t.assignee_id = u.id
GROUP BY u.id, u.name, t.project_id;

Điểm đáng chú ý: window function chạy sau GROUP BY trong logical order (GROUP BY là bước 3, window function nằm trong SELECT là bước 5). Vì vậy RANK() OVER (...) có thể dùng kết quả của COUNT(...) FILTER (...) — aggregate được tính xong trước khi window function đọc giá trị đó.

id | name    | project_id | done_count | project_rank
---+---------+------------+------------+--------------
 1 | An      |          1 |          8 |            1
 2 | Binh    |          1 |          5 |            2
 3 | Chi     |          1 |          5 |            2
 4 | An      |          2 |          3 |            1

RANK() tạo gap: project_rank = 4 không xuất hiện sau hai người rank 2. Dùng DENSE_RANK() nếu muốn liên tục không gap.

9. Deep Dive — Window functions

📚 Deep Dive — Window functions

Ghi chú: Đọc PG tutorial trước để xây nền, xem PG docs formal grammar khi cần syntax chi tiết, đọc Ben-Gan khi muốn master frame clause và advanced pattern như gap-and-island.

10. Tóm tắt

  • Window function = "cửa sổ nhìn nhóm row liên quan" — KHÔNG collapse như GROUP BY, mỗi row vẫn xuất hiện trong kết quả.
  • Cú pháp: func() OVER (PARTITION BY ... ORDER BY ...) — PARTITION BY chia group, ORDER BY xác định thứ tự trong window.
  • 4 use case: ranking (ROW_NUMBER/RANK/DENSE_RANK), aggregate over window (SUM/AVG/COUNT OVER), lag/lead so sánh row trước/sau, frame cho running total và moving average.
  • WHERE không thấy window function result — wrap subquery hoặc CTE để filter theo column window.
  • DISTINCT ON (col) là alternative ngắn gọn cho "top-1 per group" trong PostgreSQL, window function cần thiết cho top-N hoặc khi cần rank value.
  • Window function chạy sau GROUP BY trong logical order — có thể OVER trên aggregate result.
  • Module 3 bài 7 của khoá này: rank/lag/running total — 4 pattern thực chiến. Module 8 của khoá này: CTE wrap pattern cho window filter.

11. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao window function giữ row trong khi GROUP BY collapse? Khi nào dùng window, khi nào dùng GROUP BY?

GROUP BY gom nhiều row có cùng key thành 1 row per group — collapse là cơ chế cốt lõi để tính aggregate trên nhóm. Mỗi row trong kết quả đại diện cho cả nhóm, mất individual identity.

Window function không gom row — mỗi row trong input vẫn xuất hiện trong output. Thay vào đó, engine tính thêm một giá trị cross-row (rank, aggregate của nhóm, lag/lead) và gắn vào từng row như một column mới.

Dùng GROUP BY khi bạn cần 1 kết quả per group — tổng đơn hàng per tháng, số task per user. Dùng window khi bạn cần giữ row detail đồng thời biết vị trí của row đó trong nhóm — rank task trong project, so sánh doanh thu tháng này với tháng trước.

Q2
Vì sao WHERE rn = 1 sau ROW_NUMBER() OVER (...) AS rn báo lỗi? Nêu 3 cách fix và tradeoff.

Logical processing order: WHERE chạy ở bước 2, trước SELECT (bước 5). Window function nằm trong SELECT — tại thời điểm WHERE chạy, column rn chưa được tính, chưa tồn tại.

Fix 1 — Subquery: Wrap toàn bộ SELECT (bao gồm window) thành subquery, filter ở outer query. Universal, chạy trên mọi database hỗ trợ window function.

Fix 2 — CTE: WITH ranked AS (...) SELECT * FROM ranked WHERE rn = 1. Dễ đọc hơn khi query phức tạp; Module 8 của khoá này đi sâu CTE. Tương đương subquery về execution plan trên PostgreSQL.

Fix 3 — DISTINCT ON (PostgreSQL-only): SELECT DISTINCT ON (project_id) ... ORDER BY project_id, due_at. Ngắn nhất cho top-1 per group nhưng không portable, không cho rank value trong kết quả.

Q3
Phân biệt AVG(x) OVER (PARTITION BY p) vs SELECT AVG(x) FROM t GROUP BY p. Cho ví dụ output khác nhau.

AVG(x) OVER (PARTITION BY p) trả về mỗi row gốc kèm giá trị AVG của nhóm — số row output bằng số row input. Mỗi task giữ nguyên id, title và thêm avg của project.

SELECT AVG(x) FROM t GROUP BY p trả về 1 row per group — chỉ có project_idavg, mất toàn bộ task detail.

Ví dụ: 3 task của project 1 với my_days = 2, 4, 6:

  • Window: 3 rows, mỗi row có project_avg = 4.0 kèm idtitle riêng.
  • GROUP BY: 1 row, chỉ có project_id = 1avg = 4.0.

Dùng window khi cần so sánh từng task với trung bình nhóm. Dùng GROUP BY khi chỉ cần trung bình nhóm, không cần chi tiết.

Q4
DISTINCT ON vs ROW_NUMBER + WHERE rn=1 — khi nào dùng cái nào?

DISTINCT ON — ngắn, đọc nhanh, tốt cho top-1 per group đơn giản. Nhược: chỉ PostgreSQL, không lấy được rank value (không biết đây là hạng mấy), không filter top-N với N vượt 1.

ROW_NUMBER + subquery/CTE — portable hơn (mọi DB hỗ trợ window), lấy được rank value, dễ mở rộng thành top-N chỉ cần đổi WHERE rn = 1 thành WHERE rn <= 5. Dùng khi cần: (1) top-N với N vượt 1, (2) cần giữ rank value trong output, (3) cần portable sang MySQL/SQL Server.

Rule thực chiến: TaskFlow cần "task mới nhất per project để hiển thị" — DISTINCT ON đủ. Cần "top 3 task per project cho leaderboard" hoặc "rank của task trong nhóm" — dùng ROW_NUMBER.

Q5
Window function chạy ở vị trí nào trong SQL logical processing order? Điều này ảnh hưởng gì đến việc dùng window kết hợp GROUP BY?

Window function nằm trong SELECT — bước 5 trong logical order. Thứ tự đầy đủ: FROM (1) → WHERE (2) → GROUP BY (3) → HAVING (4) → SELECT với window (5) → ORDER BY (6) → LIMIT (7).

Vì window chạy sau GROUP BY, bạn có thể dùng window trên aggregate result: RANK() OVER (PARTITION BY project_id ORDER BY COUNT(*) DESC)COUNT(*) được tính xong ở bước GROUP BY trước khi window đọc giá trị đó.

Hệ quả quan trọng: WHEREHAVING không thấy window column — filter theo window result phải wrap subquery hoặc CTE. ORDER BY (bước 6) thấy window column và có thể sort theo rn hay bất kỳ alias window nào.

Q6
TaskFlow cần '1 query trả về task due gần nhất per assignee, kèm median due (tính bằng ngày từ hôm nay) của toàn project đó'. Có thể viết 1 query không? Phác thảo approach.

Có thể viết 1 query với 2 window function khác nhau:

SELECT DISTINCT ON (t.assignee_id)
t.id,
t.assignee_id,
t.project_id,
t.due_at,
PERCENTILE_CONT(0.5) WITHIN GROUP (
  ORDER BY t.due_at - CURRENT_DATE
) OVER (PARTITION BY t.project_id) AS project_median_days
FROM tasks t
ORDER BY t.assignee_id, t.due_at;

Hoặc dùng ROW_NUMBER nếu muốn portable hơn:

SELECT * FROM (
SELECT
  t.id, t.assignee_id, t.project_id, t.due_at,
  ROW_NUMBER() OVER (PARTITION BY t.assignee_id ORDER BY t.due_at) AS rn,
  PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY t.due_at - CURRENT_DATE)
    OVER (PARTITION BY t.project_id) AS project_median_days
FROM tasks t
) sub
WHERE rn = 1;

Hai window (PARTITION BY assignee_id cho ranking, PARTITION BY project_id cho median) hoạt động độc lập trong cùng SELECT. PostgreSQL tính từng window một lần qua bảng.

Bài tiếp theo: Window rank/lag/running total — 4 pattern phổ biến nhất analytics

Bài này có giúp bạn hiểu bản chất không?