Custom sort order with CASE
Short answer: ORDER BY CASE col WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END lets you define an arbitrary sort order for non-alphabetical categories: priority levels, status workflows, custom rankings. Standard SQL, works on every engine. The CASE returns a sort key that the engine then orders normally.
Canonical SQL #
-- Single CASE expression in ORDER BY.
WITH tickets(id, priority, created_at) AS (
VALUES
(1, 'low', TIMESTAMP '2026-04-01 09:00'),
(2, 'critical', TIMESTAMP '2026-04-01 10:00'),
(3, 'medium', TIMESTAMP '2026-04-02 08:30'),
(4, 'high', TIMESTAMP '2026-04-02 11:15')
)
SELECT *
FROM tickets
ORDER BY
CASE priority
WHEN 'critical' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
WHEN 'low' THEN 4
ELSE 5 -- catch-all for unexpected values
END,
created_at DESC; -- secondary sort within same priorityExample #
Common patterns #
-- Custom status ordering (workflow stage order).
SELECT *
FROM orders
ORDER BY
CASE status
WHEN 'pending' THEN 1
WHEN 'paid' THEN 2
WHEN 'shipped' THEN 3
WHEN 'delivered' THEN 4
WHEN 'cancelled' THEN 99
WHEN 'refunded' THEN 99
ELSE 98
END;
-- "Pinned items first" pattern.
WITH articles(id, title, is_pinned, published_at) AS (
VALUES
(1, 'Q1 results', FALSE, DATE '2026-03-01'),
(2, 'Welcome post', TRUE, DATE '2025-01-15'),
(3, 'Changelog', FALSE, DATE '2026-04-10')
)
SELECT *
FROM articles
ORDER BY
CASE WHEN is_pinned THEN 0 ELSE 1 END, -- pinned first
published_at DESC;
-- Alphabetical, but with one item forced to top.
WITH countries(code, name) AS (
VALUES ('BR', 'Brazil'), ('DE', 'Germany'), ('US', 'United States')
)
SELECT *
FROM countries
ORDER BY
CASE WHEN code = 'US' THEN 0 ELSE 1 END,
name;
-- Distance-based custom sort: closest tier first.
SELECT *
FROM products
ORDER BY
CASE
WHEN price < 50 THEN 1
WHEN price < 100 THEN 2
WHEN price < 500 THEN 3
ELSE 4
END,
name;
-- Sort by a list (alternative pattern with a join).
WITH tickets(id, priority, title) AS (
VALUES
(1, 'low', 'doc typo'),
(2, 'critical', 'prod down'),
(3, 'medium', 'add filter'),
(4, 'high', 'data missing')
)
SELECT t.*
FROM tickets t
JOIN (VALUES ('critical', 1), ('high', 2), ('medium', 3), ('low', 4))
AS rank_order(priority, rank)
ON rank_order.priority = t.priority
ORDER BY rank_order.rank;Common mistakes #
- No
ELSEbranch: rows with unexpected values getNULLsort keys, which fall to the end (or start, depending on engine default). Add anELSEto control where they land. - Sort key collisions: if two priorities map to the same number (
'cancelled' THEN 99and'refunded' THEN 99), the order between them is non-deterministic. Add a tie-breaker column (ORDER BY CASE..., id). - Repeating the
CASEinSELECTandORDER BY: write it once with an alias if your engine supports referencing aliases inORDER BY(most do). - Performance on huge tables: a
CASEinORDER BYevaluates per row. For frequently-queried sorts, apriority_ranklookup column or table is faster.
Dialect notes #
ORDER BY CASEis standard ANSI SQL — works on every engine.- MySQL has
FIELD():ORDER BY FIELD(priority, 'critical', 'high', 'medium', 'low')is shorter syntax for the same operation. Returns the position ofpriorityin the list (or 0 if not found, which sorts to the top). - Postgres / DuckDB: no
FIELD, but you can use array position:ORDER BY array_position(ARRAY['critical','high','medium','low'], priority). - **For sorts that change over time** (today's "priority order" is different from last quarter's), store the rank in a real table and
JOINto it — keeps the rule centralized.
For any custom-ordered category that appears in more than 2-3 places, store the order in a lookup table. A priority_levels table with (priority, sort_rank) rows, joined into queries, beats copy-pasting the same CASE block across 30 dashboards. When the product team adds "blocker" between "critical" and "high," you change one row, not 30 queries. The same pattern applies to status workflows, severity levels, and any business taxonomy that has a meaningful order. Detail: CASE expressions.