SQL · Guides · October 30, 2026 · 12 min read
SQL ORDER BY: ASC, DESC, multiple columns, NULLS LAST and top N

ORDER BY sorts the rows of a query result. Write it at the end of the query, list the columns to sort by, and add ASC (ascending, the default) or DESC (descending) after each one: ORDER BY range DESC puts the longest-range aircraft first. With several columns, the second one only breaks ties in the first. Without ORDER BY, the database returns rows in no guaranteed order, so any query whose order matters, and every query with LIMIT, needs one. Two details trip people up most: where NULLs go (it differs between databases, so write NULLS FIRST or NULLS LAST), and ties at the cut-off of a top N.
All examples run on the airline database from the Nomto SQL course, in PostgreSQL syntax on DuckDB (the engine of the Nomto SQL room), and every result table is the real output.
ORDER BY syntax
SELECT columns
FROM table
WHERE condition
ORDER BY column1 [ASC | DESC] [NULLS FIRST | NULLS LAST],
column2 [ASC | DESC] ...
LIMIT n;
ORDER BY comes after WHERE, GROUP BY and HAVING, and before LIMIT. It is also one of the last steps the database performs, which is why it can use aliases from SELECT (more on that below).
Ascending and descending order
The nine aircraft models, sorted by range from shortest to longest:
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 is the same as ORDER BY range ASC. For longest first, write ORDER BY range DESC. Numbers sort by value, dates and timestamps from earliest to latest, and text alphabetically (with caveats covered in the section on sorting text).
ORDER BY multiple columns
List several columns separated by commas. Rows are sorted by the first; the second is used only when the first is equal, and so on. Each column gets its own direction. Aircraft grouped by manufacturer A to Z, and within each manufacturer by range from longest to shortest:
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 |
A frequent mistake is writing ORDER BY maker, range DESC and expecting both columns to be descending. DESC applies only to the column right before it; maker here is still ascending.
Sort by alias, column position or expression
ORDER BY can refer to:
- a column alias from
SELECT:makerabove is not a real column, it is an alias for an expression; - an expression:
ORDER BY split_part(model, ' ', 1)gives the same result; - a column number:
ORDER BY 2 DESCsorts by the second column of theSELECTlist; - a column that is not selected at all:
SELECT city FROM airports ORDER BY timezoneis valid.
Sorting a grouped result by its aggregate, by alias or by position:
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 |
Positions (ORDER BY 2) are handy while exploring, but fragile in saved queries: add a column at the start of SELECT and the sort silently changes. Prefer names in anything someone else will read.
Two restrictions to know. An alias can be used on its own but not always inside an expression: PostgreSQL rejects ORDER BY flights * 2 when flights is an alias. And with SELECT DISTINCT, PostgreSQL requires every ORDER BY expression to appear in the select list (“for SELECT DISTINCT, ORDER BY expressions must appear in select list”); DuckDB is more lenient, but don’t rely on it.
Custom sort order with CASE
Alphabetical order of statuses is meaningless; the lifecycle order is what people want to see. Give each value a number with CASE and sort by it:
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 |
The ELSE puts anything unexpected at the end instead of the start. The same trick pins one value to the top: ORDER BY city = 'Moscow' DESC, city lists Moscow first and the rest alphabetically (in PostgreSQL and DuckDB, true sorts after false, hence DESC). MySQL also has ORDER BY FIELD(status, 'Scheduled', 'On Time', ...), which does not exist elsewhere. More CASE patterns are in the CASE WHEN guide.
NULLS FIRST and NULLS LAST
Which flights departed with the longest delay? 16,348 flights have not departed yet, so their actual_departure, and the computed delay, is 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 |
Change it to NULLS FIRST and the top of the list is flights with no delay figure at all:
| 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 |
What happens if you write neither depends on the database:
| database | NULLs in ASC | NULLs in DESC |
|---|---|---|
| PostgreSQL, Oracle | last | first |
| DuckDB | last | last |
| MySQL, SQL Server, SQLite | first | last |
So the same ORDER BY delay_min DESC LIMIT 5 returns the real top 5 in DuckDB but five NULLs in PostgreSQL. PostgreSQL, Oracle, DuckDB and SQLite (since 3.30) accept NULLS FIRST/NULLS LAST. MySQL and SQL Server do not; there, sort by a flag first: ORDER BY delay_min IS NULL, delay_min DESC in MySQL, or ORDER BY CASE WHEN delay_min IS NULL THEN 1 ELSE 0 END, delay_min DESC in SQL Server. The NULL guide has more on how NULL behaves.
ORDER BY with LIMIT: top N rows
ORDER BY plus LIMIT is how you get “the top N”. The four most frequent destinations from 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 |
Ties at the cut-off
Three airports, GOJ, PES and ULV, each have 122 flights, and only two fit into the top 4. Without the second sort column, arrival_airport, the database is free to pick any two of them, and it does: five runs of the query with only ORDER BY flights DESC gave five different pairs in 3rd and 4th place (PES and ULV, GOJ and PES, ULV and PES, GOJ and ULV, PES and GOJ). A report that changes on every refresh is a bug. Two fixes:
- Add a tie-breaker that makes the order unique, as above. The result is stable, but the cut is still arbitrary: ULV is out only because of its name.
- Keep all tied rows. PostgreSQL 13+ and Oracle support
FETCH FIRST 4 ROWS WITH TIES, SQL Server hasTOP 4 WITH TIES. DuckDB and MySQL have neither; useRANK():
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 |
Five rows, because three airports share 3rd place. For the top N within each group (top 3 routes per airport, for example) see the window functions guide.
LIMIT in other databases
| database | top 3 |
|---|---|
| PostgreSQL, DuckDB, MySQL, SQLite | ORDER BY range DESC LIMIT 3 |
| SQL Server | SELECT TOP 3 ... ORDER BY range DESC |
| Standard SQL (PostgreSQL, Oracle 12c+, SQL Server, DuckDB) | ORDER BY range DESC FETCH FIRST 3 ROWS ONLY |
Pagination with OFFSET
OFFSET skips rows before LIMIT starts counting. Page 2 of the aircraft list, three per page:
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 |
The standard form OFFSET 3 ROWS FETCH NEXT 3 ROWS ONLY returns the same rows and also works in SQL Server, where OFFSET requires an ORDER BY. Pagination only makes sense with a unique order: with ties, a row can appear on two pages or on none. On large tables, deep offsets are slow because the database still has to produce and skip all the earlier rows; applications usually switch to “keyset” pagination (WHERE range < last_seen_value ORDER BY range DESC LIMIT 3).
Sorting text: numbers stored as text and letter case
Text is compared character by character, so numbers stored as text sort “wrongly”. Seat numbers on the Airbus A319 are text like 1A or 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 |
Row 10 comes before row 1 because “1” followed by “0” sorts before “1” followed by “A”. Sort by the numeric part, then the letter:
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 |
In PostgreSQL, replace regexp_extract(seat_no, '^[0-9]+') with substring(seat_no FROM '^[0-9]+'). The same problem hits IDs and amounts that were loaded as text: '10' < '9'. The real fix is to store them as numbers.
Letter case is the other surprise. DuckDB compares text by character codes, so all capitals come before all lower-case letters:
SELECT x FROM (VALUES ('banana'), ('Cherry'), ('apple'), ('Apple')) t(x)
ORDER BY x;
| x |
|---|
| Apple |
| Cherry |
| apple |
| banana |
PostgreSQL with a typical en_US collation returns apple, Apple, banana, Cherry; MySQL and SQL Server with their default case-insensitive collations treat apple and Apple as equal. To get the same order everywhere, sort by lower(x), with x as a tie-breaker:
| x |
|---|
| Apple |
| apple |
| banana |
| Cherry |
Random order
To pick random rows, sort by a random number:
SELECT member_id, signup_channel
FROM loyalty_members
ORDER BY random()
LIMIT 3;
| member_id | signup_channel |
|---|---|
| 17425 | app |
| 26750 | web |
| 5661 | app |
Your three rows will differ; that is the point. The function is random() in PostgreSQL, DuckDB and SQLite, RAND() in MySQL, and SQL Server uses ORDER BY NEWID(). Sorting a whole large table to keep three rows is expensive; for a sample of a big table, PostgreSQL and DuckDB offer TABLESAMPLE (and DuckDB also USING SAMPLE).
Common mistakes
- Relying on the “natural” order. Without
ORDER BYthe order is whatever the engine finds convenient, and it can change between runs or after an update. ORDER BYin a subquery or view. Most databases do not promise to keep it in the outer query (SQL Server rejects it withoutTOP). Sort in the outermost query.DESCon one column, expecting it on all. Each column needs its own direction.- No tie-breaker before
LIMIT. Ties make top N and pagination unstable. - Forgetting where
NULLs go. WriteNULLS LASTorNULLS FIRSTwhenever the sort column can be empty. - Sorting numbers stored as text. Cast them, or fix the column type.
These rules come up again in the basic SQL queries guide and the SQL cheat sheet.
FAQ
What is the default sort order in SQL ORDER BY?
Ascending (ASC): smallest numbers, earliest dates and the start of the alphabet first. DESC reverses it. Where NULLs go by default depends on the database, so state it with NULLS FIRST or NULLS LAST.
How do I ORDER BY multiple columns?
List them separated by commas, each with its own direction: ORDER BY maker ASC, range DESC. The second column only decides the order among rows that are equal in the first.
Can I use a column alias in ORDER BY?
Yes. ORDER BY runs after SELECT, so SELECT COUNT(*) AS flights ... ORDER BY flights DESC works in all major databases. You cannot use the alias in WHERE, which runs before SELECT.
How do I put NULL values last?
Add NULLS LAST after the column: ORDER BY delay_min DESC NULLS LAST. In MySQL and SQL Server, which lack this syntax, sort by an “is null” flag first, for example ORDER BY delay_min IS NULL, delay_min DESC in MySQL.
Can I ORDER BY a column that is not in SELECT?
Yes, except with SELECT DISTINCT (in PostgreSQL and SQL Server) or when the column is not available after GROUP BY. In a grouped query, you can sort by a grouped column or by an aggregate such as COUNT(*).
Does ORDER BY slow down a query?
Sorting costs time on large results, especially without LIMIT. With ORDER BY ... LIMIT n databases use a top-N sort that keeps only n rows in memory, and an index on the sort column can avoid sorting altogether in PostgreSQL, MySQL or SQL Server.
Also in Español, Português (Brasil), Tiếng Việt, 简体中文