ORDER BY in MySQL
The ORDER BY clause sorts a query result set. Without it, MySQL makes no guarantee about
row order — the physical storage order, join order, or optimizer choices can all change between
executions. Always add ORDER BY when order matters.
Basic Syntax
-- Ascending order (default) SELECT first_name, last_name, salary FROM employees ORDER BY last_name ASC; -- Descending order SELECT product_name, price FROM products ORDER BY price DESC; -- ASC is the default, so these are equivalent: -- ORDER BY last_name -- ORDER BY last_name ASC
Multi-Level Sort Strategies
You can sort by several columns. The second column breaks ties in the first, the third breaks ties in the second, and so on. Each column can have its own direction.
-- Sort by department ASC, then salary DESC within each department SELECT first_name, last_name, department_id, salary FROM employees ORDER BY department_id ASC, salary DESC; -- Three-level sort: region, then country, then city SELECT city, country, region, population FROM cities ORDER BY region ASC, country ASC, city ASC; -- Mix directions freely SELECT product_name, category, price, stock FROM products ORDER BY category ASC, price DESC, stock ASC;
ORDER BY Column Position (Legacy Syntax)
You can reference SELECT columns by their ordinal position (1-based). This is valid SQL but fragile — if someone reorders the SELECT list, the sort changes silently.
-- 1 = first_name, 2 = last_name, 3 = salary SELECT first_name, last_name, salary FROM employees ORDER BY 3 DESC, 2 ASC;
ORDER BY with Expressions and Aliases
-- Sort by a calculated value
SELECT product_name, price, quantity,
price * quantity AS line_total
FROM order_items
ORDER BY price * quantity DESC;
-- MySQL allows aliases in ORDER BY (evaluated after SELECT)
SELECT product_name, price * quantity AS line_total
FROM order_items
ORDER BY line_total DESC;
-- Sort by string length
SELECT username, LENGTH(username) AS len
FROM users
ORDER BY len DESC;
-- Sort by day of week (1=Sunday in MySQL)
SELECT event_name, event_date
FROM events
ORDER BY DAYOFWEEK(event_date), event_date;NULL Ordering — The MySQL Workaround
In MySQL, NULL values are treated as lower than any non-NULL value for sorting purposes. This means NULLs appear first in ASC order and last in DESC order.
MySQL does not support the standard SQL NULLS FIRST / NULLS LAST syntax directly
(it was added only in MySQL 8.0.26 for window functions). Use the IS NULL trick instead.
-- Default: NULLs first in ASC order SELECT name, manager_id FROM employees ORDER BY manager_id ASC; -- manager_id: NULL, NULL, 1, 2, 3 -- Force NULLs LAST in ASC order SELECT name, manager_id FROM employees ORDER BY (manager_id IS NULL) ASC, manager_id ASC; -- (manager_id IS NULL) returns 0 for non-null, 1 for null -- So 0s (non-null) sort first, 1s (null) sort last -- Result: 1, 2, 3, NULL, NULL -- Force NULLs FIRST in DESC order (they would normally be last) SELECT name, manager_id FROM employees ORDER BY (manager_id IS NULL) DESC, manager_id DESC; -- Result: NULL, NULL, 3, 2, 1 -- Force NULLs LAST in DESC order (the default already does this) SELECT name, manager_id FROM employees ORDER BY manager_id DESC; -- Result: 3, 2, 1, NULL, NULL (NULLs naturally last in DESC)
Goal | ORDER BY expression |
|---|---|
NULLs first, ASC (default) | ORDER BY col ASC |
NULLs last, ASC | ORDER BY (col IS NULL) ASC, col ASC |
NULLs first, DESC | ORDER BY (col IS NULL) DESC, col DESC |
NULLs last, DESC (default) | ORDER BY col DESC |
FIELD() for Custom Enum-Like Sort Order
FIELD(val, v1, v2, v3, ...) returns the 1-based position of val in the list, or 0 if not found.
This lets you define a completely custom sort order — ideal for status columns and priority queues.
-- Sort orders by a business-defined status priority SELECT id, status, total, created_at FROM orders ORDER BY FIELD(status, 'urgent', 'pending', 'processing', 'shipped', 'delivered', 'cancelled'); -- 'urgent' = 1, 'pending' = 2, ..., items not in list = 0 (sort first) -- Push unlisted statuses to the end with a secondary sort SELECT id, status, total FROM orders ORDER BY FIELD(status, 'urgent', 'pending', 'processing', 'shipped', 'delivered', 'cancelled') = 0 ASC, FIELD(status, 'urgent', 'pending', 'processing', 'shipped', 'delivered', 'cancelled') ASC; -- Custom day-of-week sort starting Monday SELECT event_name, event_day FROM events ORDER BY FIELD(event_day, 'Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday');
ORDER BY with LIMIT — Top-N and Pagination
Combining ORDER BY with LIMIT is the standard pattern for pagination, top-N queries, and most-recent-N queries.
-- Top 5 most expensive products SELECT product_name, price FROM products ORDER BY price DESC LIMIT 5; -- Most recent 10 orders SELECT id, customer_id, total, created_at FROM orders ORDER BY created_at DESC LIMIT 10; -- Page 3 of results (10 per page): skip 20, take 10 SELECT id, title FROM articles ORDER BY published_at DESC LIMIT 10 OFFSET 20; -- Leaderboard: top 100 players with ties handled by secondary sort SELECT player_name, score, games_played FROM leaderboard ORDER BY score DESC, games_played ASC LIMIT 100;
ORDER BY with LIMIT — Optimizer Optimization
When MySQL sees ORDER BY col DESC LIMIT N, and there is a suitable index on col, the optimizer
can satisfy the query by reading just the first N entries from the index in reverse order — it
does not need to sort the entire table. This makes top-N queries extremely fast.
Without an index, MySQL sorts the entire result set (filesort) before applying LIMIT.
-- Without index: sorts all 5M rows, then returns top 10 (slow) EXPLAIN SELECT * FROM orders ORDER BY created_at DESC LIMIT 10; -- Extra: Using filesort <-- sorts everything -- With index: reads 10 entries from index in reverse, done (fast) ALTER TABLE orders ADD INDEX idx_created_at (created_at); EXPLAIN SELECT * FROM orders ORDER BY created_at DESC LIMIT 10; -- type: index, Extra: NULL <-- walks index backward
Performance: Filesort Algorithm Deep Dive
When MySQL cannot use an index for ORDER BY, it uses a filesort. MySQL has two filesort algorithms:
Single-pass: reads all columns needed by the query into a sort buffer, sorts, then outputs. Faster when rows are wide but uses more sort buffer memory.
Two-pass: reads only the sort key + row pointer into the buffer, sorts, then re-reads the rows from disk to fetch the actual columns. More disk I/O but uses less memory.
MySQL chooses between them based on max_length_for_sort_data and row size. Increasing
sort_buffer_size prevents spilling to disk.
-- Check sort buffer size SHOW VARIABLES LIKE 'sort_buffer_size'; -- default: 256K SHOW VARIABLES LIKE 'max_length_for_sort_data'; -- default: 4096 bytes -- Increase sort buffer for large sorts (session level) SET SESSION sort_buffer_size = 4 * 1024 * 1024; -- 4 MB -- Check if a query is spilling to disk SHOW STATUS LIKE 'Sort_merge_passes'; -- > 0 means disk spill occurred
Covering Index for ORDER BY
If all columns in the SELECT list, WHERE clause, and ORDER BY clause are present in one index,
MySQL reads only the index — never touching the table rows. EXPLAIN shows Using index.
-- Covering index: status (equality) + created_at (range/sort) + id + total (SELECT) ALTER TABLE orders ADD INDEX idx_cover (status, created_at, id, total); SELECT id, total FROM orders WHERE status = 'pending' ORDER BY created_at DESC LIMIT 10;
id | select_type | type | key | rows | Extra 1 | SIMPLE | ref | idx_cover | 12 | Using index
Composite Index Column Order for ORDER BY
The golden rule for composite indexes with ORDER BY:
- Put equality WHERE columns first (they narrow the range).
- Put the ORDER BY column last (so sorted order in the index matches the query).
- A range condition (
>,<,BETWEEN) breaks the ability to use the index for ORDER BY.
-- Good: equality on status, then ORDER BY created_at -- Index: (status, created_at) — MySQL walks the index in order SELECT id FROM orders WHERE status = 'pending' ORDER BY created_at DESC; -- Bad: range on created_at breaks ORDER BY index usage -- Index: (status, created_at) can't be used for ORDER BY here SELECT id FROM orders WHERE status = 'pending' AND created_at > '2024-01-01' ORDER BY total DESC; -- total is not in the index after a range
ORDER BY in Subqueries and Views
-- ORDER BY in derived table without LIMIT is ignored in MySQL 8.0+ SELECT * FROM ( SELECT id, name FROM products ORDER BY name -- this ORDER BY is ignored ) AS sub ORDER BY name; -- this ORDER BY is what actually sorts -- If you need ORDER BY inside a subquery, add LIMIT SELECT * FROM ( SELECT id, name FROM products ORDER BY name LIMIT 999999999 ) AS sub; -- ORDER BY now honored (with LIMIT present)
Stable Sort Guarantee (MySQL 8.0)
MySQL 8.0 guarantees a stable sort: when rows compare as equal on all ORDER BY columns, they appear in a consistent (though unspecified) order. In MySQL 5.7 and earlier, equal rows could appear in different sequences between executions. To get a fully deterministic result, always include a unique column (like the primary key) as the tiebreaker.
-- Fully deterministic: department sort, salary tiebreaker, PK final tiebreaker SELECT id, first_name, department_id, salary FROM employees ORDER BY department_id ASC, salary DESC, id ASC; -- id is the PK -- guarantees same row order on every run
Practical Use Cases
-- Leaderboard (score desc, earlier join date wins ties) SELECT username, score, joined_at FROM players ORDER BY score DESC, joined_at ASC LIMIT 50; -- Social feed (promoted posts first, then newest) SELECT id, title, promoted, published_at FROM posts ORDER BY promoted DESC, published_at DESC LIMIT 20; -- Invoice aging report (oldest unpaid first) SELECT invoice_id, customer, due_date, amount FROM invoices WHERE paid = 0 ORDER BY due_date ASC; -- Admin user list (locked accounts last, then alpha) SELECT username, account_locked, created_at FROM users ORDER BY account_locked ASC, username ASC;
Summary Table
Scenario | Recommended Approach |
|---|---|
Sort ascending | ORDER BY col ASC (or just ORDER BY col) |
Sort descending | ORDER BY col DESC |
Multiple sort keys | ORDER BY col1, col2 DESC |
Custom sort order | ORDER BY FIELD(col, val1, val2, ...) |
NULLs last in ASC | ORDER BY (col IS NULL) ASC, col ASC |
NULLs first in DESC | ORDER BY (col IS NULL) DESC, col DESC |
Top-N query | ORDER BY col DESC LIMIT N |
Pagination | ORDER BY col LIMIT n OFFSET m |
Deterministic order | Add primary key as final tiebreaker |
Avoid filesort | Add index matching ORDER BY column(s) |
Covering index | Include WHERE + ORDER BY + SELECT cols in index |
Best Practices
Always specify ORDER BY when the order of results matters — never rely on implicit ordering.
Use column names, not ordinal positions, in ORDER BY for maintainable code.
Add indexes on columns frequently used in ORDER BY, especially combined with WHERE filters.
Use covering indexes on hot queries that combine WHERE, ORDER BY, and a small SELECT list.
Check EXPLAIN output for "Using filesort" and evaluate whether an index would help.
Pair ORDER BY with LIMIT to efficiently implement top-N and pagination queries.
Use FIELD() for domain-specific sort orders like status priority queues.
Always include a unique column (PK) as the final ORDER BY tiebreaker for deterministic paging.
Use the IS NULL trick to control NULL placement since MySQL lacks NULLS FIRST/LAST syntax.