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

ORDER BY trong SQL: sắp xếp tăng dần, giảm dần, nhiều cột, NULLS LAST và top N

Các máy bay xếp hàng trên đường băng từ nhỏ đến lớn, có biển đánh số thứ tự

ORDER BY sắp xếp các dòng trong kết quả truy vấn. Bạn viết nó ở cuối truy vấn, liệt kê các cột cần sắp xếp và thêm ASC (tăng dần, mặc định) hoặc DESC (giảm dần) sau mỗi cột: ORDER BY range DESC đưa máy bay có tầm bay xa nhất lên đầu. Với nhiều cột, cột thứ hai chỉ phân định thứ tự khi cột thứ nhất bằng nhau. Không có ORDER BY, cơ sở dữ liệu trả về các dòng theo thứ tự không được bảo đảm, nên mọi truy vấn cần thứ tự, và mọi truy vấn có LIMIT, đều cần nó. Hai chi tiết hay làm người ta vấp nhất: NULL nằm ở đâu (mỗi hệ một kiểu, nên hãy viết NULLS FIRST hoặc NULLS LAST), và các giá trị bằng nhau ở ranh giới của top N.

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ú pháp tương đương cho SQL Server và MySQL, hai hệ rất phổ biến ở các công ty Việt Nam, được ghi chú ở từng phần.

Cú pháp ORDER BY

SELECT columns
FROM table
WHERE condition
ORDER BY column1 [ASC | DESC] [NULLS FIRST | NULLS LAST],
         column2 [ASC | DESC] ...
LIMIT n;

ORDER BY đứng sau WHERE, GROUP BY và HAVING, và trước LIMIT. Nó cũng là một trong những bước cuối cùng cơ sở dữ liệu thực hiện, nên nó dùng được bí danh (alias) từ SELECT (xem phần bên dưới).

Sắp xếp tăng dần và giảm dần

Chín mẫu máy bay, sắp theo tầm bay từ ngắn nhất đến xa nhất:

SELECT model, range
FROM aircrafts
ORDER BY range;
model range
Cessna 208 Caravan 1200
Bombardier CRJ-200 2700
Sukhoi Superjet-100 3000
Boeing 737-300 4200
Airbus A321-200 5600
Airbus A320-200 5700
Airbus A319-100 6700
Boeing 767-300 7900
Boeing 777-300 11100

ORDER BY range giống hệt ORDER BY range ASC. Muốn xa nhất lên đầu, viết ORDER BY range DESC. Số được sắp theo giá trị, ngày giờ từ sớm nhất đến muộn nhất, còn chuỗi theo bảng chữ cái (kèm vài lưu ý ở phần sắp xếp chuỗi, đặc biệt với tiếng Việt).

ORDER BY nhiều cột

Liệt kê nhiều cột, cách nhau bằng dấu phẩy. Các dòng được sắp theo cột đầu tiên; cột thứ hai chỉ được dùng khi cột đầu bằng nhau, và cứ thế. Mỗi cột có chiều riêng. Máy bay nhóm theo hãng sản xuất từ A đến Z, và trong mỗi hãng theo tầm bay từ xa nhất đến ngắn nhất:

SELECT split_part(model, ' ', 1) AS maker, model, range
FROM aircrafts
ORDER BY maker, range DESC;
maker model range
Airbus Airbus A319-100 6700
Airbus Airbus A320-200 5700
Airbus Airbus A321-200 5600
Boeing Boeing 777-300 11100
Boeing Boeing 767-300 7900
Boeing Boeing 737-300 4200
Bombardier Bombardier CRJ-200 2700
Cessna Cessna 208 Caravan 1200
Sukhoi Sukhoi Superjet-100 3000

Lỗi hay gặp là viết ORDER BY maker, range DESC và nghĩ cả hai cột đều giảm dần. DESC chỉ áp dụng cho cột ngay trước nó; ở đây maker vẫn tăng dần.

Sắp theo bí danh, vị trí cột hoặc biểu thức

ORDER BY có thể tham chiếu đến:

Sắp xếp kết quả đã nhóm theo giá trị tổng hợp, bằng bí danh hoặc vị trí:

SELECT status, COUNT(*) AS flights
FROM flights
GROUP BY status
ORDER BY flights DESC;   -- or ORDER BY 2 DESC, or ORDER BY COUNT(*) DESC
status flights
Arrived 16707
Scheduled 15383
On Time 518
Cancelled 414
Departed 58
Delayed 41

Vị trí (ORDER BY 2) tiện khi khám phá dữ liệu, nhưng dễ vỡ trong truy vấn lưu lại: thêm một cột vào đầu SELECT là thứ tự âm thầm thay đổi. Trong những gì người khác sẽ đọc, hãy dùng tên.

Hai giới hạn cần biết. Bí danh dùng một mình thì được, nhưng không phải lúc nào cũng dùng được bên trong biểu thức: PostgreSQL từ chối ORDER BY flights * 2 khi flights là bí danh. Và với SELECT DISTINCT, PostgreSQL yêu cầu mọi biểu thức trong ORDER BY phải có mặt trong danh sách chọn (“for SELECT DISTINCT, ORDER BY expressions must appear in select list”); DuckDB dễ dãi hơn, nhưng đừng dựa vào đó.

Thứ tự tùy chỉnh với CASE

Thứ tự bảng chữ cái của các trạng thái chẳng có ý nghĩa gì; người đọc muốn thấy theo vòng đời chuyến bay. Gán cho mỗi giá trị một con số bằng CASE rồi sắp theo nó:

SELECT status, COUNT(*) AS flights
FROM flights
GROUP BY status
ORDER BY CASE status
           WHEN 'Scheduled' THEN 1
           WHEN 'On Time'   THEN 2
           WHEN 'Delayed'   THEN 3
           WHEN 'Departed'  THEN 4
           WHEN 'Arrived'   THEN 5
           ELSE 6
         END;
status flights
Scheduled 15383
On Time 518
Delayed 41
Departed 58
Arrived 16707
Cancelled 414

ELSE đẩy mọi giá trị bất ngờ xuống cuối thay vì lên đầu. Cùng mẹo đó ghim một giá trị lên trên cùng: ORDER BY city = 'Moscow' DESC, city liệt kê Moscow trước, phần còn lại theo bảng chữ cái (trong PostgreSQL và DuckDB, true xếp sau false, nên cần DESC). MySQL còn có ORDER BY FIELD(status, 'Scheduled', 'On Time', ...), hàm không tồn tại ở nơi khác. Thêm nhiều mẫu CASE trong bài CASE WHEN trong SQL.

NULLS FIRST và NULLS LAST

Những chuyến bay nào cất cánh trễ nhất? 16.348 chuyến chưa cất cánh, nên actual_departure của chúng, và độ trễ tính ra, là NULL:

SELECT flight_no, departure_airport, scheduled_departure,
       EXTRACT(EPOCH FROM actual_departure - scheduled_departure) / 60 AS delay_min
FROM flights
ORDER BY delay_min DESC NULLS LAST
LIMIT 5;
flight_no departure_airport scheduled_departure delay_min
PG0589 PEE 2026-07-25 15:30:00 277
PG0164 DME 2026-07-25 15:25:00 268
PG0364 KRR 2026-07-15 11:45:00 267
PG0568 OVS 2026-08-09 16:15:00 260
PG0096 URJ 2026-07-30 16:35:00 258

Đổi thành NULLS FIRST (với LIMIT 3), đầu danh sách là những chuyến hoàn toàn không có số liệu độ trễ:

flight_no departure_airport scheduled_departure delay_min
PG0402 DME 2026-09-05 12:25:00 NULL
PG0405 DME 2026-08-12 09:35:00 NULL
PG0404 DME 2026-08-12 19:05:00 NULL

Nếu bạn không viết gì, kết quả phụ thuộc vào hệ quản trị:

hệ quản trị NULL khi ASC NULL khi DESC
PostgreSQL, Oracle cuối đầu
DuckDB cuối cuối
MySQL, SQL Server, SQLite đầu cuối

Vì vậy cùng ORDER BY delay_min DESC LIMIT 5 trả về top 5 thật trong DuckDB nhưng năm giá trị NULL trong PostgreSQL. PostgreSQL, Oracle, DuckDB và SQLite (từ 3.30) chấp nhận NULLS FIRST/NULLS LAST. MySQL và SQL Server thì không; ở đó hãy sắp theo một cờ trước: ORDER BY delay_min IS NULL, delay_min DESC trong MySQL, hoặc ORDER BY CASE WHEN delay_min IS NULL THEN 1 ELSE 0 END, delay_min DESC trong SQL Server. Bài NULL trong SQL nói kỹ hơn về cách NULL hoạt động.

ORDER BY với LIMIT: top N dòng

ORDER BY cộng LIMIT là cách lấy “top N”. Bốn điểm đến có nhiều chuyến nhất từ sân bay Sheremetyevo (SVO):

SELECT arrival_airport, COUNT(*) AS flights
FROM flights
WHERE departure_airport = 'SVO'
GROUP BY arrival_airport
ORDER BY flights DESC, arrival_airport
LIMIT 4;
arrival_airport flights
LED 305
BZK 183
GOJ 122
PES 122

Giá trị bằng nhau ở ranh giới

Ba sân bay GOJ, PES và ULV đều có 122 chuyến, và chỉ hai trong số đó lọt vào top 4. Không có cột sắp xếp thứ hai arrival_airport, cơ sở dữ liệu được tự do chọn hai cái bất kỳ, và nó làm đúng như thế: năm lần chạy truy vấn chỉ với ORDER BY flights DESC cho ra năm cặp khác nhau ở vị trí 3 và 4 (PES và ULV, GOJ và PES, ULV và PES, GOJ và ULV, PES và GOJ). Một báo cáo thay đổi mỗi lần làm mới là một lỗi. Có hai cách sửa:

  1. Thêm cột phân định để thứ tự là duy nhất, như ở trên. Kết quả ổn định, nhưng việc cắt vẫn tùy tiện: ULV bị loại chỉ vì tên của nó.
  2. Giữ mọi dòng bằng nhau. PostgreSQL 13+ và Oracle hỗ trợ FETCH FIRST 4 ROWS WITH TIES, SQL Server có TOP 4 WITH TIES. DuckDB và MySQL không có cả hai; hãy dùng RANK():
SELECT arrival_airport, flights
FROM (
  SELECT arrival_airport, COUNT(*) AS flights,
         RANK() OVER (ORDER BY COUNT(*) DESC) AS rnk
  FROM flights
  WHERE departure_airport = 'SVO'
  GROUP BY arrival_airport
) t
WHERE rnk <= 4
ORDER BY flights DESC, arrival_airport;
arrival_airport flights
LED 305
BZK 183
GOJ 122
PES 122
ULV 122

Năm dòng, vì ba sân bay cùng đứng thứ 3. Để lấy top N trong từng nhóm (ví dụ top 3 tuyến của mỗi sân bay), xem bài hàm cửa sổ (window function) trong SQL.

LIMIT ở các hệ khác

hệ quản trị top 3
PostgreSQL, DuckDB, MySQL, SQLite ORDER BY range DESC LIMIT 3
SQL Server SELECT TOP 3 ... ORDER BY range DESC
SQL chuẩn (PostgreSQL, Oracle 12c+, SQL Server, DuckDB) ORDER BY range DESC FETCH FIRST 3 ROWS ONLY

Phân trang với OFFSET

OFFSET bỏ qua một số dòng trước khi LIMIT bắt đầu đếm. Trang 2 của danh sách máy bay, ba dòng mỗi trang:

SELECT model, range
FROM aircrafts
ORDER BY range DESC
LIMIT 3 OFFSET 3;
model range
Airbus A320-200 5700
Airbus A321-200 5600
Boeing 737-300 4200

Dạng chuẩn OFFSET 3 ROWS FETCH NEXT 3 ROWS ONLY trả về cùng các dòng và chạy được cả trong SQL Server, nơi OFFSET bắt buộc đi kèm ORDER BY. Phân trang chỉ có nghĩa khi thứ tự là duy nhất: nếu có giá trị bằng nhau, một dòng có thể xuất hiện ở hai trang hoặc không ở trang nào. Trên bảng lớn, offset sâu chạy chậm vì cơ sở dữ liệu vẫn phải tạo ra rồi bỏ qua mọi dòng trước đó; các ứng dụng thường chuyển sang phân trang “keyset” (WHERE range < last_seen_value ORDER BY range DESC LIMIT 3).

Sắp xếp chuỗi: số lưu dạng chuỗi, chữ hoa thường và tiếng Việt

Chuỗi được so sánh từng ký tự một, nên số lưu dạng chuỗi bị sắp “sai”. Số ghế trên Airbus A319 là chuỗi như 1A hay 10C:

SELECT seat_no FROM seats WHERE aircraft_code = '319' ORDER BY seat_no LIMIT 8;
seat_no
10A
10B
10C
10D
10E
10F
11A
11B

Hàng 10 đứng trước hàng 1 vì “1” theo sau là “0” xếp trước “1” theo sau là “A”. Hãy sắp theo phần số, rồi đến chữ cái:

SELECT seat_no, fare_conditions
FROM seats
WHERE aircraft_code = '319'
ORDER BY CAST(regexp_extract(seat_no, '^[0-9]+') AS INTEGER), right(seat_no, 1)
LIMIT 8;
seat_no fare_conditions
1A Business
1C Business
1D Business
1F Business
2A Business
2C Business
2D Business
2F Business

Trong PostgreSQL, thay regexp_extract(seat_no, '^[0-9]+') bằng substring(seat_no FROM '^[0-9]+'). Cùng vấn đề này xảy ra với mã số và số tiền được nạp vào dưới dạng chuỗi: '10' < '9'. Cách sửa thật sự là lưu chúng dưới dạng số.

Chữ hoa chữ thường là bất ngờ thứ hai. DuckDB so sánh chuỗi theo mã ký tự, nên mọi chữ hoa đứng trước mọi chữ thường:

SELECT x FROM (VALUES ('banana'), ('Cherry'), ('apple'), ('Apple')) t(x)
ORDER BY x;
x
Apple
Cherry
apple
banana

PostgreSQL với collation en_US thông dụng trả về apple, Apple, banana, Cherry; MySQL và SQL Server với collation mặc định không phân biệt hoa thường coi apple và Apple là bằng nhau. Để có cùng thứ tự ở mọi nơi, hãy sắp theo lower(x), kèm x làm cột phân định:

x
Apple
apple
banana
Cherry

Sắp xếp tên tiếng Việt

Sắp theo mã ký tự còn làm hỏng thứ tự tiếng Việt: “Đ” có mã lớn hơn mọi chữ Latin cơ bản, nên Đà Nẵng rơi xuống sau Vũng Tàu:

SELECT city
FROM (VALUES ('Vũng Tàu'), ('Đà Nẵng'), ('An Giang'), ('Huế'), ('Bình Dương'), ('Cần Thơ')) t(city)
ORDER BY city;
city
An Giang
Bình Dương
Cần Thơ
Huế
Vũng Tàu
Đà Nẵng

Chỉ định collation tiếng Việt và “Đ” trở về đúng chỗ, ngay sau “D”:

SELECT city
FROM (VALUES ('Vũng Tàu'), ('Đà Nẵng'), ('An Giang'), ('Huế'), ('Bình Dương'), ('Cần Thơ')) t(city)
ORDER BY city COLLATE vi;
city
An Giang
Bình Dương
Cần Thơ
Đà Nẵng
Huế
Vũng Tàu

Ở các hệ khác: SQL Server dùng ORDER BY city COLLATE Vietnamese_CI_AS, PostgreSQL dùng collation ICU ORDER BY city COLLATE "vi-x-icu", còn MySQL 8 có utf8mb4_vi_0900_ai_ci. Nếu cột lưu họ tên đầy đủ, nhớ rằng người Việt thường sắp danh sách theo tên (chữ cuối), không theo họ, nên bạn cần tách tên ra một cột hoặc biểu thức riêng trước khi sắp.

Thứ tự ngẫu nhiên

Để chọn dòng ngẫu nhiên, hãy sắp theo một số ngẫu nhiên:

SELECT member_id, signup_channel
FROM loyalty_members
ORDER BY random()
LIMIT 3;
member_id signup_channel
17473 app
12177 web
12825 web

Ba dòng của bạn sẽ khác, và đó chính là mục đích. Hàm này là random() trong PostgreSQL, DuckDB và SQLite, RAND() trong MySQL, còn SQL Server dùng ORDER BY NEWID(). Sắp xếp cả một bảng lớn chỉ để giữ ba dòng rất tốn kém; để lấy mẫu từ bảng lớn, PostgreSQL và DuckDB có TABLESAMPLE (DuckDB còn có USING SAMPLE).

Lỗi thường gặp

Các quy tắc này xuất hiện lại trong bài câu lệnh SQL cơ bản và SQL cheat sheet.

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

ORDER BY mặc định sắp xếp tăng hay giảm?

Tăng dần (ASC): số nhỏ nhất, ngày sớm nhất và đầu bảng chữ cái đứng trước. DESC đảo ngược lại. Vị trí mặc định của NULL tùy hệ quản trị, nên hãy nói rõ bằng NULLS FIRST hoặc NULLS LAST.

Làm sao ORDER BY nhiều cột?

Liệt kê chúng, cách nhau bằng dấu phẩy, mỗi cột có chiều riêng: ORDER BY maker ASC, range DESC. Cột thứ hai chỉ quyết định thứ tự giữa những dòng bằng nhau ở cột thứ nhất.

Có dùng bí danh cột trong ORDER BY được không?

Được. ORDER BY chạy sau SELECT, nên SELECT COUNT(*) AS flights ... ORDER BY flights DESC chạy được ở mọi hệ quản trị lớn. Bạn không dùng được bí danh trong WHERE, vì WHERE chạy trước SELECT.

Làm sao đưa giá trị NULL xuống cuối?

Thêm NULLS LAST sau cột: ORDER BY delay_min DESC NULLS LAST. Trong MySQL và SQL Server, vốn không có cú pháp này, hãy sắp theo cờ “là null” trước, ví dụ ORDER BY delay_min IS NULL, delay_min DESC trong MySQL.

Có ORDER BY theo cột không có trong SELECT được không?

Được, trừ khi dùng SELECT DISTINCT (trong PostgreSQL và SQL Server) hoặc khi cột đó không còn sau GROUP BY. Trong truy vấn có nhóm, bạn có thể sắp theo cột đã nhóm hoặc theo một hàm tổng hợp như COUNT(*).

Làm sao sắp xếp đúng thứ tự chữ cái tiếng Việt?

Dùng collation tiếng Việt: COLLATE vi trong DuckDB, COLLATE Vietnamese_CI_AS trong SQL Server, COLLATE "vi-x-icu" trong PostgreSQL. Không có nó, các tên bắt đầu bằng “Đ” bị xếp sau “Z”.

ORDER BY có làm truy vấn chậm đi không?

Sắp xếp tốn thời gian với kết quả lớn, nhất là khi không có LIMIT. Với ORDER BY ... LIMIT n, các hệ quản trị dùng kiểu sắp xếp top N chỉ giữ n dòng trong bộ nhớ, và một index trên cột sắp xếp có thể giúp bỏ qua hoàn toàn bước sắp xếp trong PostgreSQL, MySQL hay SQL Server.

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