SQL cho phân tích dữ liệu · Hướng dẫn · 30 tháng 10, 2026 · 14 phút đọc

Pivot trong SQL: chuyển dòng thành cột (và unpivot) kèm ví dụ

Một bảng giờ khởi hành dài xoay thành lưới rộng gồm các tháng và các mẫu máy bay, minh họa phẳng

Pivot trong SQL là biến các giá trị của một cột thành các cột riêng, mỗi giá trị một cột, giống như PivotTable trong Excel. Cách làm chạy được ở mọi nơi là tổng hợp có điều kiện (conditional aggregation): GROUP BY theo nhãn dòng và viết một hàm tổng hợp cho mỗi cột đầu ra, mỗi hàm chỉ đếm hoặc cộng giá trị của riêng nó, SUM(CASE WHEN month = 7 THEN 1 ELSE 0 END) AS jul. Cách này chạy trên mọi hệ quản trị. Một số engine có thêm toán tử PIVOT tự viết các cột đó cho bạn (DuckDB, SQL Server, Oracle, Snowflake, BigQuery); PostgreSQL có hàm crosstab; MySQL không có cả hai. Thao tác ngược lại, unpivot, biến cột trở lại thành dòng.

Mọi ví dụ chạy trên cơ sở dữ liệu hãng hàng không của khóa SQL trên Nomto, với cú pháp PostgreSQL trên DuckDB (động cơ của Sân tập SQL trên Nomto), và mọi bảng kết quả đều là kết quả thật. Các đoạn mã cho SQL Server và crosstab của PostgreSQL được đánh dấu riêng và không được chạy ở đây.

Dữ liệu dạng dài và dạng rộng

GROUP BY theo hai cột trả về dữ liệu dạng dài (long): mỗi tổ hợp một dòng. Số chuyến bay theo mẫu máy bay và tháng:

SELECT a.model,
       date_trunc('month', f.scheduled_departure)::date AS month,
       COUNT(*) AS flights
FROM flights f
JOIN aircrafts a ON a.aircraft_code = f.aircraft_code
GROUP BY a.model, month
ORDER BY a.model, month
LIMIT 6;
model month flights
Airbus A319-100 2026-07-01 401
Airbus A319-100 2026-08-01 637
Airbus A319-100 2026-09-01 201
Airbus A321-200 2026-07-01 640
Airbus A321-200 2026-08-01 992
Airbus A321-200 2026-09-01 320

Sáu dòng đầu trong 24 dòng. Dữ liệu dạng dài là thứ cơ sở dữ liệu, biểu đồ và các bước SQL tiếp theo ưa thích. Người đọc báo cáo thường muốn bản dạng rộng (wide): mỗi mẫu máy bay một dòng, mỗi tháng một cột. Đi từ dạng này sang dạng kia chính là pivot.

Pivot bằng CASE: cách chạy được mọi nơi

Chỉ nhóm theo nhãn dòng, và tạo từng cột bằng một hàm tổng hợp chỉ nhìn vào một tháng:

SELECT a.model,
       SUM(CASE WHEN EXTRACT(month FROM f.scheduled_departure) = 7 THEN 1 ELSE 0 END) AS jul,
       SUM(CASE WHEN EXTRACT(month FROM f.scheduled_departure) = 8 THEN 1 ELSE 0 END) AS aug,
       SUM(CASE WHEN EXTRACT(month FROM f.scheduled_departure) = 9 THEN 1 ELSE 0 END) AS sep,
       COUNT(*) AS total
FROM flights f
JOIN aircrafts a ON a.aircraft_code = f.aircraft_code
GROUP BY a.model
ORDER BY total DESC;
model jul aug sep total
Cessna 208 Caravan 3035 4714 1524 9273
Bombardier CRJ-200 2953 4605 1490 9048
Sukhoi Superjet-100 2782 4326 1396 8504
Airbus A321-200 640 992 320 1952
Boeing 737-300 416 648 210 1274
Airbus A319-100 401 637 201 1239
Boeing 767-300 399 622 200 1221
Boeing 777-300 200 310 100 610

(Lịch bay kéo dài từ 12/7 đến 10/9, nên tháng 7 và tháng 9 không trọn tháng.)

Cách nó hoạt động: mỗi dòng chuyến bay đi qua cả ba biểu thức CASE, và mỗi biểu thức chỉ cộng 1 nếu dòng đó thuộc tháng của nó. Sau đó GROUP BY a.model gộp các dòng thành một dòng cho mỗi mẫu máy bay. Tổng theo dòng có sẵn miễn phí: COUNT(*) trong cùng SELECT. Mẫu thứ chín, Airbus A320-200, không có chuyến bay nào, nên phép INNER JOIN bỏ nó đi; hãy bắt đầu từ aircrafts với LEFT JOIN nếu muốn hiện cả dòng trống.

Cách này chạy nguyên văn trong PostgreSQL, MySQL, SQL Server, Oracle, SQLite và DuckDB (chỉ hàm lấy tháng khác nhau: MONTH() trong MySQL và SQL Server). Để cộng một số tiền thay vì đếm, hãy trả về giá trị đó trong nhánh THEN: SUM(CASE WHEN ... THEN amount ELSE 0 END). Bài CASE WHEN trong SQL nói kỹ về khác biệt giữa SUM và COUNT ở đây.

FILTER: cùng phép pivot, viết ngắn hơn

PostgreSQL, DuckDB và SQLite hỗ trợ mệnh đề chuẩn FILTER, dễ đọc hơn khi có nhiều cột. Vì pivot đặt các con số liên quan cạnh nhau, bạn có thể tính tỷ lệ giữa các cột mới ở bước tiếp theo. Sự kiện trong ứng dụng theo nền tảng, kèm tỷ lệ chuyển đổi:

WITH p AS (
  SELECT platform,
         COUNT(*) FILTER (WHERE event_name = 'search')         AS searches,
         COUNT(*) FILTER (WHERE event_name = 'checkout_start') AS checkouts,
         COUNT(*) FILTER (WHERE event_name = 'purchase')       AS purchases
  FROM app_events
  GROUP BY platform
)
SELECT platform, searches, checkouts, purchases,
       ROUND(100.0 * purchases / searches, 1)  AS purchases_per_100_searches,
       ROUND(100.0 * purchases / checkouts, 1) AS checkout_to_purchase_pct
FROM p
ORDER BY platform;
platform searches checkouts purchases purchases_per_100_searches checkout_to_purchase_pct
android 112610 39061 21052 18.7 53.9
ios 76957 26686 14262 18.5 53.4
web 65626 28895 16538 25.2 57.2

Bản web chuyển đổi tốt hơn ở cả hai bước. Ở dạng dài, cùng phép so sánh đó cần self join hoặc hàm cửa sổ; sau khi pivot, nó chỉ là phép tính giữa các cột. (Đây là số sự kiện thô; bảng còn chứa các sự kiện bị gửi lặp, mà bài tìm dòng trùng lặp trong SQL loại bỏ.)

MySQL, SQL Server và Oracle không có FILTER; ở đó hãy dùng SUM(CASE ...).

Pivot giá trị, không phải số đếm: MAX(CASE …)

Khi mỗi ô chỉ chứa đúng một giá trị, như một thuộc tính được lưu thành dòng, hãy dùng MAX (hoặc MIN) để lấy nó ra. Giá vé hiện hành từ Sheremetyevo, mỗi hạng vé một cột:

SELECT departure_airport, arrival_airport,
       MAX(CASE WHEN fare_conditions = 'Economy'  THEN price END) AS economy,
       MAX(CASE WHEN fare_conditions = 'Comfort'  THEN price END) AS comfort,
       MAX(CASE WHEN fare_conditions = 'Business' THEN price END) AS business
FROM fare_price_history
WHERE valid_to IS NULL
  AND departure_airport = 'SVO'
GROUP BY departure_airport, arrival_airport
ORDER BY business DESC NULLS LAST
LIMIT 5;
departure_airport arrival_airport economy comfort business
SVO UUS 693.00 null 2081.00
SVO DYR 618.00 null 1853.00
SVO MJZ 415.00 null 1246.00
SVO KJA 333.00 null 998.00
SVO RGK 313.00 null 940.00

Ở đây CASE không có ELSE, nên hạng vé bị thiếu ra NULL, và điều đó là đúng: các tuyến này không có giá Comfort (cả bảng chỉ có 10 tuyến có), và giá 0 sẽ là thông tin sai. Với số đếm thì ngược lại: 0 là câu trả lời đúng cho “không có chuyến nào”, và ELSE 0 hoặc COUNT cho ra nó. MAX cũng dùng được với chuỗi và ngày tháng, nên đây là cách chuẩn để biến các dòng khóa–giá trị (tên thuộc tính, giá trị thuộc tính) thành cột.

Toán tử PIVOT trong DuckDB

DuckDB có câu lệnh PIVOT. ON chỉ cột có giá trị sẽ thành cột, USING chỉ hàm tổng hợp, GROUP BY chỉ nhãn dòng. Với danh sách IN viết rõ, bạn tự chọn các cột và tên của chúng:

PIVOT (
  SELECT a.model, EXTRACT(month FROM f.scheduled_departure) AS m
  FROM flights f
  JOIN aircrafts a USING (aircraft_code)
)
ON m IN (7 AS jul, 8 AS aug, 9 AS sep)
USING COUNT(*)
GROUP BY model
ORDER BY model;
model jul aug sep
Airbus A319-100 401 637 201
Airbus A321-200 640 992 320
Boeing 737-300 416 648 210
Boeing 767-300 399 622 200
Boeing 777-300 200 310 100
Bombardier CRJ-200 2953 4605 1490
Cessna 208 Caravan 3035 4714 1524
Sukhoi Superjet-100 2782 4326 1396

Cùng các con số như bản CASE. Hai điểm cộng làm PIVOT của DuckDB tiện lợi. Thứ nhất, nhiều hàm tổng hợp cùng lúc, mỗi hàm tạo một cột cho mỗi giá trị:

PIVOT (
  SELECT f.departure_airport, tf.fare_conditions, tf.amount
  FROM ticket_flights tf
  JOIN flights f USING (flight_id)
  WHERE f.departure_airport IN ('SVO', 'DME', 'VKO')
)
ON fare_conditions IN ('Economy', 'Business')
USING COUNT(*) AS seg, ROUND(SUM(amount)) AS rev
GROUP BY departure_airport
ORDER BY departure_airport;
departure_airport Economy_seg Economy_rev Business_seg Business_rev
DME 126345 24288385 16183 9704569
SVO 133730 20813481 15959 7549309
VKO 53588 7078523 6035 2300182

Thứ hai, không có IN, nó tự tìm các cột trong dữ liệu:

PIVOT flights
ON status
USING COUNT(*)
GROUP BY departure_airport
ORDER BY departure_airport
LIMIT 3;
departure_airport Arrived Cancelled Delayed Departed On Time Scheduled
AAQ 67 1 0 0 2 61
ABA 119 5 1 0 4 106
AER 292 6 1 2 10 273

Rất tiện khi khám phá dữ liệu, nhưng hãy để ý cột On Time có dấu cách trong tên, và việc tập các cột giờ phụ thuộc vào dữ liệu. Xem thêm ở phần bên dưới.

PIVOT trong SQL Server (và Oracle)

Nếu bạn làm việc ở ngân hàng hoặc doanh nghiệp dùng hệ sinh thái Microsoft, nhiều khả năng bạn sẽ gặp PIVOT của SQL Server. Ở đó nó là một mệnh đề bên trong FROM. Không chạy ở đây; dạng của nó như sau:

-- SQL Server
SELECT model, [7] AS jul, [8] AS aug, [9] AS sep
FROM (
  SELECT a.model, MONTH(f.scheduled_departure) AS m, f.flight_id
  FROM flights f
  JOIN aircrafts a ON a.aircraft_code = f.aircraft_code
) AS src
PIVOT (
  COUNT(flight_id) FOR m IN ([7], [8], [9])
) AS p;

Cái bẫy: không có GROUP BY. SQL Server nhóm theo mọi cột của nguồn không được dùng trong pivot. Vì thế nguồn là một truy vấn con chỉ có model, m và flight_id; nếu chọn f.* ở đó, bạn sẽ nhận được mỗi chuyến bay một dòng thay vì mỗi mẫu máy bay một dòng. Danh sách IN nhận tên cột trong ngoặc vuông, và chỉ cho phép một hàm tổng hợp. PIVOT của Oracle trông tương tự nhưng chấp nhận nhiều hàm tổng hợp và giá trị trong nháy đơn ở IN.

PostgreSQL: crosstab

PostgreSQL không có PIVOT. Extension tablefunc cung cấp crosstab, nhận một truy vấn trả về (nhãn dòng, loại, giá trị) và trải nó ra thành cột. Không chạy ở đây:

-- PostgreSQL, needs: CREATE EXTENSION tablefunc;
SELECT *
FROM crosstab(
  $$SELECT a.model::text, EXTRACT(month FROM f.scheduled_departure)::int, COUNT(*)
    FROM flights f
    JOIN aircrafts a ON a.aircraft_code = f.aircraft_code
    GROUP BY 1, 2
    ORDER BY 1, 2$$,
  $$VALUES (7), (8), (9)$$
) AS ct(model text, jul bigint, aug bigint, sep bigint);

Bạn vẫn phải liệt kê các cột đầu ra và kiểu của chúng trong AS ct(...), truy vấn nguồn phải được sắp theo nhãn dòng, và ô trống trả về NULL chứ không phải 0. Luôn dùng dạng hai tham số như ở đây: phiên bản một tham số điền cột từ trái sang phải và đẩy giá trị sang nhầm cột khi một dòng thiếu một loại. Phần lớn người dùng PostgreSQL thấy COUNT(*) FILTER (WHERE ...) đơn giản hơn và dùng luôn cách đó.

Pivot động: khi bạn không biết trước các cột

Một truy vấn SQL phải khai báo các cột đầu ra trước khi chạy. Đó là lý do mọi cách ở trên đều liệt kê giá trị (jul, aug, sep) bằng tay, và vì sao “pivot theo mọi giá trị khác nhau, bất kể là gì” không làm được bằng một truy vấn thường trong hầu hết hệ quản trị. Các lựa chọn:

Unpivot: chuyển cột trở lại thành dòng

Unpivot biến bảng rộng thành bảng dài: mỗi cặp (dòng, cột) trở thành một dòng có một cột “tên” và một cột “giá trị”. Bạn cần nó khi dữ liệu đến dưới dạng một file Excel mỗi tháng một cột, hoặc để vẽ biểu đồ từ bảng rộng. Bắt đầu với sơ đồ ghế dạng rộng, mỗi hạng vé một cột, và unpivot nó trong DuckDB:

WITH wide AS (
  SELECT aircraft_code,
         COUNT(*) FILTER (WHERE fare_conditions = 'Economy')  AS economy,
         COUNT(*) FILTER (WHERE fare_conditions = 'Comfort')  AS comfort,
         COUNT(*) FILTER (WHERE fare_conditions = 'Business') AS business
  FROM seats
  GROUP BY aircraft_code
)
UNPIVOT wide
ON economy, comfort, business
INTO NAME fare_class VALUE seats
ORDER BY aircraft_code, fare_class
LIMIT 6;
aircraft_code fare_class seats
319 business 20
319 comfort 0
319 economy 96
320 business 20
320 comfort 0
320 economy 120

Cách thay thế chạy được mọi nơi: mỗi cột một SELECT, nối bằng UNION ALL.

SELECT aircraft_code, 'economy' AS fare_class, economy AS seats FROM wide
UNION ALL
SELECT aircraft_code, 'comfort', comfort FROM wide
UNION ALL
SELECT aircraft_code, 'business', business FROM wide;

Cách này đọc bảng một lần cho mỗi cột. PostgreSQL và DuckDB làm được trong một lần quét bằng phép JOIN LATERAL với một danh sách VALUES (SQL Server viết cùng ý đó là CROSS APPLY (VALUES ...)):

SELECT w.aircraft_code, v.fare_class, v.seats
FROM wide w
CROSS JOIN LATERAL (
  VALUES ('economy', w.economy), ('comfort', w.comfort), ('business', w.business)
) AS v(fare_class, seats)
WHERE w.aircraft_code = '773'
ORDER BY v.seats DESC;
aircraft_code fare_class seats
773 economy 324
773 comfort 48
773 business 30

(Cả hai chạy với cùng WITH wide AS (...) như ở trên.) SQL Server cũng có mệnh đề UNPIVOT: FROM wide UNPIVOT (seats FOR fare_class IN (economy, comfort, business)) AS u.

Một khác biệt cần để ý: UNPIVOT của DuckDB và UNPIVOT của SQL Server bỏ những ô NULL, nên một tuyến không có giá Comfort đơn giản là không có dòng Comfort. UNION ALL và LATERAL VALUES giữ chúng lại thành dòng có giá trị NULL. Hãy quyết định bước tiếp theo của bạn cần cách nào.

Nên dùng cách nào

tình huống cách làm
phải chạy trên mọi hệ quản trị SUM(CASE ...) / MAX(CASE ...)
PostgreSQL, DuckDB, SQLite COUNT(*) FILTER (WHERE ...)
DuckDB, khám phá nhanh PIVOT (có IN để cột ổn định)
SQL Server, Oracle mệnh đề PIVOT, hoặc CASE khi cần nhiều hàm tổng hợp
không biết trước tập cột sinh câu SQL, hoặc pivot bên ngoài cơ sở dữ liệu
từ rộng sang dài UNPIVOT, LATERAL VALUES hoặc UNION ALL

Câu hỏi thường gặp

Làm sao chuyển dòng thành cột trong SQL?

Nhóm theo cột cần giữ làm dòng và viết một hàm tổng hợp cho mỗi cột mới, mỗi hàm giới hạn ở một giá trị: SUM(CASE WHEN category = 'A' THEN amount ELSE 0 END) AS a. Cách tổng hợp có điều kiện này chạy trên mọi hệ quản trị SQL. DuckDB, SQL Server và Oracle còn có toán tử PIVOT.

PostgreSQL có PIVOT không?

Không. Hãy dùng tổng hợp có điều kiện với FILTER (WHERE ...) hoặc CASE, hoặc hàm crosstab từ extension tablefunc.

MySQL có PIVOT không?

Không. MySQL pivot bằng SUM(CASE ...) hoặc SUM(IF(...)) cùng GROUP BY. Khi danh sách cột phụ thuộc vào dữ liệu, hãy dựng truy vấn bằng GROUP_CONCAT rồi chạy nó như một prepared statement.

Có pivot được mà không biết trước tên cột không?

Không làm được bằng một truy vấn tĩnh duy nhất trong hầu hết hệ quản trị, vì các cột đầu ra phải được biết trước khi truy vấn chạy. Hãy dùng SQL động, PIVOT không có danh sách IN của DuckDB, hoặc trả về dữ liệu dạng dài rồi pivot trong công cụ BI hay bảng tính.

Pivot trong SQL khác PivotTable trong Excel thế nào?

Về ý tưởng là một: chọn nhãn dòng, cột và hàm tổng hợp. Khác biệt là trong SQL bạn phải liệt kê các cột đầu ra trước, còn Excel tự tạo chúng từ dữ liệu. Bù lại, pivot bằng SQL chạy được trên hàng triệu dòng và có thể lưu lại, chạy lại mỗi ngày.

PIVOT và GROUP BY khác nhau thế nào?

GROUP BY trả về một dòng cho mỗi tổ hợp giá trị nhóm (dạng dài). Pivot còn trải thêm một trong các cột nhóm đó ra thành các cột riêng (dạng rộng). Về bản chất, pivot là một GROUP BY với một hàm tổng hợp có điều kiện cho mỗi cột đầu ra.

Ngôn ngữ khác: English, Español, Português (Brasil), 简体中文