ข้ามไปยังเนื้อหา

Aggregate และ GROUP BY

จนถึงตอนนี้ทุก query คืน row ที่มีอยู่แล้ว aggregate function ต่างออกไป: มันอ่านหลาย row และให้ค่าสรุปหนึ่งค่า มียอดขายกี่รายการ? ยอดรวมกี่เล่ม? แต่ละข้อคือตัวเลขหนึ่งตัวที่บีบออกมาจากทั้ง column เพิ่ม GROUP BY แล้วคุณจะได้ตัวเลขสรุปหนึ่งตัวต่อกลุ่ม แทนที่จะเป็นหนึ่งตัวสำหรับทั้ง table

ทุกอย่างด้านล่างรันบน PostgreSQL 18.6 (postgres:18.6, aarch64, Docker Desktop บน Mac, กันยายน 2026) คัดลอกจาก psql fence sql รันจากบนลงล่างในฐานข้อมูลเปล่าหนึ่งตัว

ชุดข้อมูลมาตรฐานเดียวกับทั้ง module: sales 6 row รวม 2100 เล่ม (East 1150, West 950) โดยมีคู่เสมอ 400/400 ใน West

-- sql/setup.sql
CREATE TABLE authors (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
born int
);
CREATE TABLE books (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
author_id bigint REFERENCES authors (id),
published int,
copies_sold int NOT NULL DEFAULT 0
);
CREATE TABLE sales (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
book_id bigint REFERENCES books (id),
region text NOT NULL,
copies int NOT NULL
);
INSERT INTO authors (name, born) VALUES ('Mara Linde', 1968), ('Owen Pryce', 1975), ('Sela Vance', 1990);
INSERT INTO books (title, author_id, published, copies_sold) VALUES
('Quiet Harbors', 1, 2003, 450), ('The Glass Orchard', 1, 2018, 1250), ('Tidal Notes', 2, 2011, 600), ('Unlinked Draft', NULL, 2020, 0);
INSERT INTO sales (book_id, region, copies) VALUES
(2, 'East', 900), (3, 'West', 400), (1, 'West', 400), (3, 'East', 200), (2, 'West', 150), (1, 'East', 50);

เมื่อไม่มี GROUP BY aggregate function พับผลลัพธ์ทั้งหมดเหลือ row เดียว ที่พบบ่อยที่สุดคือ count, sum, avg, min และ max

-- sql/aggregate-whole.sql
SELECT
count(*) AS sale_rows,
sum(copies) AS total_copies,
avg(copies)::float8 AS avg_per_sale,
max(copies) AS biggest_sale
FROM sales;
sale_rows | total_copies | avg_per_sale | biggest_sale
-----------+--------------+--------------+--------------
6 | 2100 | 350 | 900
(1 row)

avg ของ column จำนวนเต็มคืน numeric ที่มีหลักทศนิยมมาก ครอบด้วย round(..., 2) เพื่อให้ตัวเลขเรียบร้อย (cast ::float8 ข้างบนมีไว้เพื่อให้ทุก driver ถอดรหัสค่าได้โดยไม่ต้องใช้ไลบรารี decimal)

-- sql/avg-numeric.sql
SELECT avg(copies) AS raw_avg, round(avg(copies), 2) AS rounded, pg_typeof(avg(copies)) AS type
FROM sales;
raw_avg | rounded | type
----------------------+---------+---------
350.0000000000000000 | 350.00 | numeric
(1 row)

ผลลัพธ์จาก driver สำหรับ query ในบทนี้ แต่ละอันรันหนึ่งครั้ง (pg คืนผล bigint เช่น count(*) และ sum(int) เป็น string เพราะตัวเลขของ JavaScript เก็บจำนวนเต็ม 64 บิตได้ไม่ครบ):

$ (each snippet run once against the dataset above; multi-line driver output joined onto one line)
[whole] Node.js (pg 8.23.0): { sale_rows: '6', total_copies: '2100', avg_per_sale: 350, biggest_sale: 900 }
[whole] Python (psycopg 3.3.6): (6, 2100, 350.0, 900)
[whole] Go (pgx v5.11.0): 6 2100 350 900
[whole] Rust (sqlx 0.9.0): 6 2100 350 900
[group] Node.js: [ { region: 'East', copies_in_region: '1150' }, { region: 'West', copies_in_region: '950' } ]
[group] Python: [('East', 1150), ('West', 950)]
[group] Go / Rust: East 1150 / West 950
[having] Node.js: [ { region: 'East', copies_in_region: '1150' } ]
[having] Python: [('East', 1150)]
[count] Node.js: [ { name: 'Mara Linde', row_count: '2', book_count: '2' }, { name: 'Owen Pryce', row_count: '1', book_count: '1' }, { name: 'Sela Vance', row_count: '1', book_count: '0' } ]
[count] Python: [('Mara Linde', 2, 2), ('Owen Pryce', 1, 1), ('Sela Vance', 1, 0)]

aggregate ข้าม input ที่เป็น NULL (ยกเว้น count(*) ที่นับ row) ดังนั้น avg(author_id) คือค่าเฉลี่ยของสามค่าที่ไม่ใช่ NULL ไม่ใช่สี่ค่า และ aggregate บน ศูนย์ row ให้ NULL สำหรับ sum, avg, min, max ส่วน count ให้ 0:

-- sql/aggregate-nulls.sql
-- books.author_id is NULL for one of the four books
SELECT count(*) AS books, count(author_id) AS with_author, avg(author_id) AS avg_author_id
FROM books;
-- an aggregate over ZERO rows: count is 0, everything else is NULL
SELECT count(*) AS n, sum(copies) AS total, coalesce(sum(copies), 0) AS total_or_zero
FROM sales
WHERE region = 'North';
books | with_author | avg_author_id
-------+-------------+--------------------
4 | 3 | 1.3333333333333333
(1 row)
n | total | total_or_zero
---+-------+---------------
0 | | 0
(1 row)

ถ้ารายงานต้องแสดง 0 สำหรับ “ไม่มียอดขาย” ให้ครอบด้วย coalesce(sum(copies), 0)

GROUP BY แบ่ง row เป็นถังที่มีค่าเดียวกันใน column ที่ระบุ แล้วรัน aggregate หนึ่งครั้งต่อถัง ตรงนี้เรารวมจำนวนเล่มที่ขายต่อ region

-- sql/group-by.sql
SELECT region, sum(copies) AS copies_in_region
FROM sales
GROUP BY region
ORDER BY copies_in_region DESC;
region | copies_in_region
--------+------------------
East | 1150
West | 950
(2 rows)

แต่ละ region กลายเป็นหนึ่ง row ผลลัพธ์ column region อยู่ใน SELECT ได้เพราะเป็นสิ่งที่เรา group ทุกอย่างอื่นต้องอยู่ใน aggregate เว้นแต่ถูกกำหนดโดย column ที่ group อยู่แล้ว (ดูด้านล่าง)

WHERE กรอง row เดี่ยว ๆ ก่อน จัดกลุ่ม การกรองด้วยผลของ aggregate ต้องใช้ HAVING ซึ่งทำงาน หลัง จัดกลุ่ม ตรงนี้เก็บเฉพาะ region ที่ขายเกินหนึ่งพันเล่ม

-- sql/having.sql
SELECT region, sum(copies) AS copies_in_region
FROM sales
GROUP BY region
HAVING sum(copies) > 1000
ORDER BY copies_in_region DESC;
region | copies_in_region
--------+------------------
East | 1150
(1 row)

ใช้ WHERE ทิ้ง row ที่ไม่ต้องการนับเลย และใช้ HAVING ทิ้งทั้งกลุ่มตามยอดรวม สองอย่างนี้มักอยู่ใน query เดียวกัน aggregate ใน WHERE ถูกปฏิเสธทันที:

-- @expect-error 42803
SELECT region FROM sales WHERE sum(copies) > 1000 GROUP BY region;
ERROR: aggregate functions are not allowed in WHERE
LINE 1: SELECT region FROM sales WHERE sum(copies) > 1000 GROUP BY r...
^

count(*) นับ row ส่วน count(column) นับ row ที่ column นั้นไม่เป็น NULL ความต่างสำคัญเมื่อ column อาจขาด การ join authors กับ books ด้วย LEFT JOIN แสดงให้เห็นชัด

-- sql/count-star-vs-column.sql
SELECT a.name,
count(*) AS row_count,
count(b.id) AS book_count
FROM authors AS a
LEFT JOIN books AS b ON b.author_id = a.id
GROUP BY a.name
ORDER BY a.name;
name | row_count | book_count
------------+-----------+------------
Mara Linde | 2 | 2
Owen Pryce | 1 | 1
Sela Vance | 1 | 0
(3 rows)

Sela Vance ได้ row_count เป็นหนึ่งเพราะ LEFT JOIN ยังสร้าง row ให้เธอ แต่ book_count เป็นศูนย์เพราะ b.id เป็น NULL ตรงนั้น และ count(b.id) ข้ามค่า NULL

ทุก column ใน SELECT ต้องถูก group, ถูก aggregate หรือถูกกำหนดโดย column ที่ group (functional dependency) ขอ column ที่ไม่ใช่ทั้งสามแล้ว PostgreSQL ปฏิเสธแทนที่จะเดา:

-- @expect-error 42803
SELECT a.name, b.title, count(*)
FROM authors a JOIN books b ON b.author_id = a.id
GROUP BY a.name;
ERROR: column "b.title" must appear in the GROUP BY clause or be used in an aggregate function
LINE 1: SELECT a.name, b.title, count(*)
^

ข้อยกเว้น: ถ้า group ด้วย primary key ของ table ทุก column อื่นของ table นั้นถูกกำหนดโดยมัน PostgreSQL จึงยอมรับโดยไม่ต้องลิสต์ GROUP BY a.id ทำให้ select a.name ได้ (docs):

-- sql/group-by-primary-key.sql
-- authors.id is the primary key, so a.name is functionally dependent on it: allowed
SELECT a.name, count(b.id) AS books
FROM authors a LEFT JOIN books b ON b.author_id = a.id
GROUP BY a.id
ORDER BY a.name;
name | books
------------+-------
Mara Linde | 2
Owen Pryce | 1
Sela Vance | 0
(3 rows)

FILTER (WHERE ...) จำกัด aggregate หนึ่งตัวให้ใช้กับบาง row ยอดรวมแบบมีเงื่อนไขหลายตัวจึงอยู่ในการผ่าน table รอบเดียว และ count(DISTINCT col) นับค่าที่ไม่ใช่ NULL และไม่ซ้ำ:

-- sql/filter-clause.sql
SELECT
sum(copies) AS total,
sum(copies) FILTER (WHERE region = 'East') AS east,
sum(copies) FILTER (WHERE region = 'West') AS west,
count(DISTINCT book_id) AS books_sold
FROM sales;
total | east | west | books_sold
-------+------+------+------------
2100 | 1150 | 950 | 3
(1 row)

ROLLUP เพิ่ม row ยอดย่อยและยอดรวม: หลังบรรทัดต่อเล่มของแต่ละ region จะมี row ที่ book_id เป็น NULL (ยอดย่อยของ region) และ row สุดท้ายที่เป็น NULL ทั้งคู่คือยอดรวม 2100:

-- sql/rollup.sql
SELECT region, book_id, sum(copies) AS copies
FROM sales
GROUP BY ROLLUP (region, book_id)
ORDER BY region NULLS LAST, book_id NULLS LAST;
region | book_id | copies
--------+---------+--------
East | 1 | 50
East | 2 | 900
East | 3 | 200
East | | 1150
West | 1 | 400
West | 2 | 150
West | 3 | 400
West | | 950
| | 2100
(9 rows)

GROUPING SETS ระบุการจัดกลุ่มที่ต้องการเป๊ะ (ที่นี่ต่อ region ต่อเล่ม และทั้งหมด) ฟังก์ชัน grouping() แยก NULL ของยอดย่อยออกจาก NULL จริงในข้อมูล:

-- sql/grouping-sets.sql
SELECT region, book_id, sum(copies) AS copies, grouping(region, book_id) AS g
FROM sales
GROUP BY GROUPING SETS ((region), (book_id), ())
ORDER BY g, region, book_id;
region | book_id | copies | g
--------+---------+--------+---
East | | 1150 | 1
West | | 950 | 1
| 1 | 450 | 2
| 2 | 1050 | 2
| 3 | 600 | 2
| | 2100 | 3
(6 rows)

หกแถวบอกอะไรเกี่ยวกับ plan ได้ไม่มาก หัวข้อนี้สร้าง big_sales ที่มี 500 000 row ใน 5 region:

-- sql/aggregate-plans-setup.sql
CREATE TABLE big_sales (
id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
region text NOT NULL,
copies int NOT NULL
);
INSERT INTO big_sales (region, copies)
SELECT (ARRAY['East', 'West', 'North', 'South', 'Central'])[1 + g % 5], g % 1000
FROM generate_series(1, 500000) AS g;
ANALYZE big_sales;
CREATE TABLE
INSERT 0 500000
ANALYZE
-- sql/plan-group-by.sql
EXPLAIN (ANALYZE, COSTS OFF)
SELECT region, sum(copies) FROM big_sales GROUP BY region;
QUERY PLAN
-----------------------------------------------------------------------------------------------------------
Finalize GroupAggregate (actual time=62.620..64.583 rows=5.00 loops=1)
Group Key: region
Buffers: shared hit=2711
-> Gather Merge (actual time=62.594..64.556 rows=10.00 loops=1)
Workers Planned: 1
Workers Launched: 1
Buffers: shared hit=2711
-> Sort (actual time=61.122..61.123 rows=5.00 loops=2)
Sort Key: region
Sort Method: quicksort Memory: 25kB
Buffers: shared hit=2711
Worker 0: Sort Method: quicksort Memory: 25kB
-> Partial HashAggregate (actual time=61.097..61.099 rows=5.00 loops=2)
Group Key: region
Batches: 1 Memory Usage: 32kB
Buffers: shared hit=2703
Worker 0: Batches: 1 Memory Usage: 32kB
-> Parallel Seq Scan on big_sales (actual time=0.006..17.918 rows=250000.00 loops=2)
Buffers: shared hit=2703
Planning:
Buffers: shared hit=90
Planning Time: 0.240 ms
Execution Time: 64.664 ms
(23 rows)

อ่านจากด้านในออกมา: Parallel Seq Scan อ่านครึ่งหนึ่งของ table ในแต่ละสอง process (loops=2, rows=250000.00 ต่อตัว) แต่ละตัวคำนวณ Partial HashAggregate ของ 5 กลุ่ม (hash table 32 kB) ผลบางส่วนห้า row ถูก sort และ merge โดย Gather Merge และ Finalize GroupAggregate รวมเข้าด้วยกัน ทั้ง query ใช้ 65 ms บน Mac เครื่องนี้ (500 000 row, 1 worker) มีเพียง 5 row ที่ออกจากการ aggregate จากที่ scan 500 000: นี่คือข้อโต้แย้งเรื่อง “ส่งข้อมูลผ่านเครือข่ายน้อย” ของการ aggregate ในฐานข้อมูล

แล้ว HAVING ทำงานที่ไหน? เงื่อนไขที่ไม่พูดถึง aggregate ถูกผลักลงไปใต้การ aggregate เข้าไปใน scan (Filter: (region = 'East') ใต้ Parallel Seq Scan อีกสี่ region จึงไม่เคยถูก aggregate) ส่วนเงื่อนไขที่ต้องใช้ aggregate ยังอยู่ด้านบนเป็น Filter บน node finalize:

-- sql/plan-having.sql
-- a HAVING that does not use an aggregate is moved below the aggregation
EXPLAIN (COSTS OFF)
SELECT region, sum(copies) FROM big_sales GROUP BY region HAVING region = 'East';
-- one that needs the aggregate stays above it
EXPLAIN (COSTS OFF)
SELECT region, sum(copies) FROM big_sales GROUP BY region HAVING sum(copies) > 1000;
QUERY PLAN
-----------------------------------------------------
Finalize GroupAggregate
-> Gather
Workers Planned: 1
-> Partial GroupAggregate
-> Parallel Seq Scan on big_sales
Filter: (region = 'East'::text)
(6 rows)
QUERY PLAN
--------------------------------------------------------
Finalize GroupAggregate
Group Key: region
Filter: (sum(copies) > 1000)
-> Gather Merge
Workers Planned: 1
-> Sort
Sort Key: region
-> Partial HashAggregate
Group Key: region
-> Parallel Seq Scan on big_sales
(10 rows)

ดังนั้น HAVING ที่ไม่มี aggregate จบลงเป็นตัวกรองของ scan อยู่ดี แต่ให้เขียนตัวกรอง row ใน WHERE: มันบอกสิ่งที่คุณหมายถึงและไม่ต้องพึ่งการ optimize นั้น

ผู้ช่วย AI ถูกขอ “ผู้เขียนและจำนวนหนังสือที่เขียนแต่ละคน” และเขียนแบบนี้ โดยเก็บผู้เขียนที่ไม่มีหนังสือไว้ด้วย LEFT JOIN:

🐛 หา bug ในโค้ดนี้

-- AI comment: "authors and how many books each has written"
SELECT a.name, count(*) AS books
FROM authors a
LEFT JOIN books b ON b.author_id = a.id
GROUP BY a.name
ORDER BY a.name;
เฉลย

count(*) นับ row ที่ join แล้ว และ LEFT JOIN สร้างหนึ่ง row ที่เติม NULL ให้ผู้เขียนที่ไม่มีหนังสือ Sela Vance ไม่เคยเขียนอะไรแต่ถูกรายงานว่ามีหนึ่งเล่ม ไม่มี error:

-- sql/stb-wrong.sql
SELECT a.name, count(*) AS books
FROM authors a
LEFT JOIN books b ON b.author_id = a.id
GROUP BY a.name
ORDER BY a.name;
name | books
------------+-------
Mara Linde | 2
Owen Pryce | 1
Sela Vance | 1
(3 rows)

ให้นับ column จากฝั่ง ทางเลือก แทน: count(b.id) ข้าม NULL และรายงาน 0 (primary key เป็นตัวเลือกที่ดีเพราะไม่มีวันเป็น NULL ใน row จริง)

-- sql/stb-fixed.sql
SELECT a.name, count(b.id) AS books
FROM authors a
LEFT JOIN books b ON b.author_id = a.id
GROUP BY a.name
ORDER BY a.name;
name | books
------------+-------
Mara Linde | 2
Owen Pryce | 1
Sela Vance | 0
(3 rows)

มอง GROUP BY เป็นการแยก row ใส่กล่องที่ติดป้าย (หนึ่งกล่องต่อหนึ่งชุดค่าที่ group ไม่ซ้ำกัน) แล้วรัน aggregate function หนึ่งครั้งต่อกล่อง SELECT ถูกประเมินต่อกล่อง จึงแสดงได้เพียงป้าย, aggregate ของสิ่งที่อยู่ข้างใน หรือสิ่งที่ป้ายกำหนดได้อยู่แล้ว (ข้อยกเว้น primary key) WHERE ตัดสินว่า row ไหนเข้ากล่อง HAVING ตัดสินว่ากล่องไหนถูกเก็บ ในทางกายภาพ PostgreSQL เติมกล่องด้วย hash table (HashAggregate) หรือเดินผ่าน input ที่เรียงแล้ว (GroupAggregate) และคำนวณกล่องบางส่วนใน parallel worker แล้ว merge ได้

  • ทุก column ใน SELECT ที่ไม่ได้ครอบด้วย aggregate ต้องอยู่ใน GROUP BY หรือถูกกำหนดโดย primary key ที่ group ไม่เช่นนั้น PostgreSQL ส่ง 42803
  • ใช้ count(*) นับ row และ count(col) นับค่าที่ไม่ใช่ NULL ใช้ count(DISTINCT col) นับค่าที่ไม่ซ้ำและไม่ใช่ NULL
  • WHERE กรอง row ก่อนจัดกลุ่ม HAVING กรองกลุ่มหลังจัดกลุ่ม ใส่ aggregate ใน WHERE ไม่ได้ (42803)
  • aggregate ข้าม NULL สำหรับ sum, avg, min และ max ด้วย และคืน NULL บน input ว่าง ส่วน count คืน 0 ใช้ coalesce เมื่อต้องการตัวเลข
  • Node.js คืนผล aggregate แบบ bigint เป็น string แปลงอย่างตั้งใจ (Number(...), BigInt(...))
ตัวเลือกBenefitCost
aggregate ด้วย GROUP BY ใน PostgreSQLรันใกล้ข้อมูล ใช้ parallel worker ได้ ส่ง 5 row แทน 500 000 (plan ข้างบน)ต้องเขียน SQL ให้ถูก รวมถึงกฎของ GROUP BY/HAVING
ดึงทุก row มาสรุปในโค้ดแอปเขียนง่าย เปลี่ยนตรรกะได้อิสระเปลืองแบนด์วิดท์ในการโอนข้อมูล และไม่ scale เมื่อ table โต
FILTER หลายตัวใน query เดียวscan รอบเดียวได้หลายยอดรวมแบบมีเงื่อนไขรายการ SELECT กว้างและอ่านยากขึ้น
ROLLUP / GROUPING SETSยอดย่อยและยอดรวมในรอบเดียวNULL ในผลลัพธ์แปลได้สองอย่าง ใช้ grouping() แยก
  • “PostgreSQL บังคับให้ลิสต์ทุก column ที่ select ใน GROUP BY” ไม่จริงเมื่อ group ด้วย primary key: GROUP BY a.id กับ SELECT a.name ใช้ได้
  • “count(*) กับ count(col) เหมือนกันหลัง LEFT JOIN” count(*) นับ row ที่เติม NULL (Sela Vance: 1) ส่วน count(b.id) ไม่นับ (0)
  • “sum ของเซตว่างคือ 0” มันคือ NULL มีแต่ count ที่ให้ 0
  • “HAVING ใช้กับ aggregate เท่านั้น” ใส่เงื่อนไขใดก็ได้ของกลุ่ม เงื่อนไขที่ไม่ใช่ aggregate ถูก planner ผลักลงไปที่ scan แต่ WHERE คือที่ที่ตรงไปตรงมา
  • “การ aggregate ทำโดยดึง row มาก่อน” aggregate รันในฐานข้อมูล แบบขนานเมื่อเป็นไปได้ มีเพียง row ของกลุ่มที่เดินทางไปถึง client
count(*) กับ count(some_column) ต่างกันอย่างไร?
clause ไหนกรองกลุ่มตามผลของ aggregate?
column ที่ไม่ใช่ aggregate อยู่ใน SELECT แต่ไม่อยู่ใน GROUP BY และไม่ถูกกำหนดโดย primary key ที่ group จะเกิดอะไรขึ้น?
SELECT sum(copies) FROM sales WHERE region = 'North' คืนอะไรเมื่อไม่มี row ของ North?
ในบทเรียน HAVING region = 'East' บน table 500 000 row กรอง row ที่ไหน?