GROUP BY 把鍵的值相同的列收成一組,所以鍵決定了一組代表什麼,而後續的 HAVING 與 SELECT 全部是對那個對象提問。

選錯鍵的時候查詢照樣跑得動,只是每一組代表的對象與寫查詢的人預期的不同。

外連接之後用右表的欄位當分組鍵

書店的三位顧客裡只有佳穎下過單,兩張都是她的。要找出一張單都沒下的顧客,用 LEFT JOIN 接起來之後按訂單那一側的顧客編號分組:

1SELECT 訂單.顧客編號 AS 分組鍵, count(*) AS 這組幾列,
2       count(訂單.顧客編號) AS 有值幾列, group_concat(顧客.姓名) AS 裝了誰   -- group_concat 把一組裡的名字串起來
3FROM 顧客 LEFT JOIN 訂單 ON 訂單.顧客編號 = 顧客.顧客編號
4GROUP BY 訂單.顧客編號;

MySQL、SQLite 與 DuckDB 的答案一致:

1分組鍵  這組幾列  有值幾列  裝了誰
2NULL    2         0         宗翰,雅文
31       2         2         佳穎,佳穎

宗翰與雅文被併進同一組。 他們沒有配到訂單,外連接給他們的 訂單.顧客編號 都是 NULL,而 GROUP BY 把值相同的列收成一組——GROUP BY 對 NULL 的處理與比較運算不同:鍵同樣是 NULL 的那些列會被收進同一組,所有沒配到的列因此共用同一個鍵。分組是少數 NULL 表現得像一般值的地方,它在比較與 NOT IN 上的表現剛好相反,那兩處的表現寫在 SQL.6 連接之後的列數與空缺;這些 NULL 是連接補上的、不是資料裡本來的空值,兩種來源的分別在 Outer Join(外連接)。

接著用 HAVING count(訂單.顧客編號) = 0 挑出沒下過單的那一組,這個條件本身是對的:它確實選中了那一組,因為 count(欄位) 只數有值的列而那一組一列都沒有。選中的是一組,而這一組底下是宗翰與雅文兩列。

輸出非分組欄時各家引擎的反應

輸出顧客姓名時,姓名不在分組鍵裡,於是三家引擎在這裡分岔:

1SELECT 顧客.姓名 FROM 顧客
2LEFT JOIN 訂單 ON 訂單.顧客編號 = 顧客.顧客編號
3GROUP BY 訂單.顧客編號 HAVING count(訂單.顧客編號) = 0;

MySQL 8 預設拒絕:

1ERROR 1055 (42000): Expression #1 of SELECT list is not in GROUP BY clause and contains
2nonaggregated column ... incompatible with sql_mode=only_full_group_by

DuckDB 同樣拒絕。SQLite 接受,從分組鍵是 NULL 的那一組裡任意挑一列回傳——只回宗翰,雅文消失了,而且沒有任何錯誤訊息。

而拒絕的 MySQL 與 DuckDB 擋下的是輸出非分組欄這個寫法,不是選錯的鍵:把姓名補進 GROUP BY 之後三家都照跑,在共用資料上答案也是對的,因為三位顧客不同名。

1SELECT 顧客.姓名 FROM 顧客
2LEFT JOIN 訂單 ON 訂單.顧客編號 = 顧客.顧客編號
3GROUP BY 訂單.顧客編號, 顧客.姓名      -- 鍵仍是訂單表的顧客編號,只補上姓名
4HAVING count(訂單.顧客編號) = 0;
5-- 共用資料:宗翰、雅文
6
7INSERT INTO 顧客 VALUES (4, '宗翰');   -- 第二位宗翰,同樣沒下過單
8-- 重跑同一段查詢:宗翰、雅文
9-- 沒下過單的顧客有三位而結果只有兩列:兩位宗翰的鍵都是 (NULL, 宗翰),被併成一組

鍵錯在它讓不同的顧客共用一個值,而這件事要等資料裡出現同名的人才看得到。選錯鍵的查詢在語法上完全合法,SQL.13 查詢的合法性與答案的正確性:引擎檢查的範圍、答案錯掉的成因與查證方法 把它歸進問錯了問題的那一類。

選分組鍵的判斷標準:一組代表的對象

一組要代表一位顧客,鍵就是顧客的識別欄位:

1SELECT 顧客.姓名 FROM 顧客
2LEFT JOIN 訂單 ON 訂單.顧客編號 = 顧客.顧客編號
3GROUP BY 顧客.顧客編號, 顧客.姓名
4HAVING count(訂單.顧客編號) = 0;
5-- 宗翰、雅文

現在三家引擎都給出兩列。分組鍵換掉之後,沒有任何一組的鍵是 NULL 了——宗翰與雅文各自有不同的顧客編號,所以各自成組,而每一組的訂單計數都是零。

判斷方法:先說出「我要每一組代表什麼」,鍵就是那個對象的識別欄位。 說不出來的時候,多半是這一題根本不需要分組。

鍵還要涵蓋一件事:SELECT 裡沒有被聚合的欄位全部要進 GROUP BY。上面補的 姓名 就是為此——少了它 MySQL 與 DuckDB 會拒絕(正是上一節那個錯誤)。

而補進去的欄位必須由鍵決定——鍵的每一個值只對到這個欄位的一個值,否則分組的粒度會跟著變。 這種關係在資料庫理論裡叫函數相依(functional dependency),MySQL 的 ERROR 1055 完整訊息寫的 not functionally dependent on columns in GROUP BY clause 指的就是它。一個顧客編號只對到一個姓名,所以補姓名不影響分組;要求的方向是從鍵到欄位,反方向不必成立——兩位宗翰就是同一個姓名對到兩個顧客編號,而按顧客編號分組時他們仍然各自成組。下單日不由顧客編號決定:把它補進 GROUP BY,同一位顧客的每一個下單日各自成組,計數從「這位顧客有幾張單」變成「這位顧客在這一天下了幾張單」,而引擎不會報錯:

1SELECT 顧客.姓名, 訂單.下單日, count(訂單.顧客編號) AS 張數 FROM 顧客
2LEFT JOIN 訂單 ON 訂單.顧客編號 = 顧客.顧客編號
3GROUP BY 顧客.顧客編號, 顧客.姓名, 訂單.下單日;
4-- 佳穎 | 2026-03-02 | 1
5-- 佳穎 | 2026-03-09 | 1      佳穎被拆成兩組
6-- 宗翰 |            | 0
7-- 雅文 |            | 0
8-- 拿掉下單日、只按顧客編號與姓名分組時:佳穎 | 2、宗翰 | 0、雅文 | 0

所以不由鍵決定的欄位不能靠補進鍵來輸出,要用聚合包起來(max(下單日) 這種),讓它回到「這一組的某個值」而不是「這一組的分法」。

鍵只決定一組由誰構成;算完之後原本的列還留不留,是分組與視窗函數分開的地方,SQL.10 分組與視窗函數:各自的產出、選用的依據與 LAG、LEAD 的相鄰列 用同一批訂單並排兩種產出。

不需要分組的問題:存在性判斷

把分組鍵換成顧客編號、再補上姓名的那個修正讓查詢回出正確答案,而更前面還有一個問題沒被問:這一題需要分組嗎。

從業務事實推一次。沒消費過的顧客,在訂單表裡不會有任何一列。 這句話有一個可以直接驗證的推論——「他的訂單數是零」這件事,從訂單表本身問不出來:

1SELECT 顧客編號, count(*) FROM 訂單 GROUP BY 顧客編號;
2-- (1, 2)

只有佳穎。宗翰與雅文不在結果裡,因為訂單表沒有他們的列,所以沒有一組可以代表他們,也就沒有計數可以是零。

那個零是外連接造出來的。 LEFT JOIN 替他們各生一列全是 NULL 的假訂單,count(欄位) 數那一列得到零。所以「計數等於零」是查詢自己製造出來再讀回去的產物,而不是資料本身帶著的性質。

計數預設有列可以數,而這一題的對象恰好是什麼都沒有的那些人。這就是分組那條路從一開始就對不上的來源,比鍵選錯更上一層。

翻成 SQL 最貼字面的形式是存在性判斷:

1SELECT 姓名 FROM 顧客
2WHERE NOT EXISTS (SELECT 1 FROM 訂單 WHERE 訂單.顧客編號 = 顧客.顧客編號);
3-- 宗翰、雅文

一句話對一句話——「找不到對應訂單的顧客」。沒有分組、沒有計數。同一件事也寫得成外連接,兩者都對:

1SELECT 顧客.姓名 FROM 顧客
2LEFT JOIN 訂單 ON 訂單.顧客編號 = 顧客.顧客編號
3WHERE 訂單.訂單編號 IS NULL;   -- 訂單編號只有在外連接補出來的列上是 NULL
4-- 宗翰、雅文

差別在描述的路徑——NOT EXISTS 直接說「找不到」,外連接那一版先讓每位顧客都保留下來、再挑出補了 NULL 的那些。SQL.5 ON 與 WHERE:連接條件與篩選條件各自的職責 從條件該放哪一邊的角度走同一題,兩篇的結論一致。

GROUP BY 是換單位的工具,用在單看一列答不出來的問題上——「這位顧客下了幾張單」「哪張訂單被評價超過兩次」那一類。判斷沿用 SQL.2 子句的求值順序:這個條件需不需要看到組裡的其他列才能回答。不需要就留在 WHERE,連 GROUP BY 都不必寫。求值順序那一篇走過從 FROM 到 LIMIT 每一步處理的單位,分組前後從列換成組是其中變化最大的一步。

三種寫法各自描述什麼、以及 NOT IN 為什麼在這裡是危險的那一個,在 SQL.8 IN、EXISTS 與 JOIN:列數、可取用的欄位與 NULL 的處理。