ORDER BY и LIMIT в SQL: сортировка, топ-N и постраничный вывод
ORDER BY и LIMIT в SQL: сортировка по нескольким колонкам, NULLS FIRST и LAST, LIMIT OFFSET, ничьи в топ-N и keyset-пагинация.
Содержание статьи
Менеджер выгружает из внутренней админки список самых дорогих платежей постранично, по пять строк. На второй странице он находит три платежа, которые уже видел на первой, а несколько других не попадаются ему вовсе. Запрос простой: order by amount desc limit 5 offset 5. Ошибки в нём нет, есть недосказанность: у 142 платежей одинаковая сумма, и база вправе расставить их как угодно. Ниже — как ORDER BY и LIMIT работают на самом деле и где у них подвохи.
Коротко
- ORDER BY сортирует итоговый результат, LIMIT оставляет первые N строк после сортировки. Без ORDER BY порядок строк не определён.
- По умолчанию сортировка по возрастанию (ASC), DESC — по убыванию. Направление задаётся для каждой колонки отдельно.
- Если значения сортировки повторяются, порядок равных строк не гарантирован. Добавляйте последним ключом уникальную колонку.
- PostgreSQL ставит NULL в конец при ASC и в начало при DESC. DuckDB — в конец в обоих случаях. Пишите
nulls firstилиnulls lastявно. - OFFSET заставляет базу прочитать и выбросить все пропущенные строки. Для глубоких страниц используйте keyset:
where (paid_at, payment_id) < (...). - ORDER BY в подзапросе не задаёт порядок внешнего запроса.
Синтаксис ORDER BY и LIMIT
ORDER BY задаёт порядок строк в результате запроса: список колонок или выражений, у каждого — направление ASC или DESC и, при необходимости, положение NULL. LIMIT ограничивает число строк, которые вернутся после сортировки, а OFFSET пропускает заданное число строк перед ними. Логически обе клаузы выполняются последними, после SELECT.
Из последнего следует важное: в ORDER BY уже доступны алиасы из SELECT, а LIMIT обрезает результат, который отсортирован целиком. Никакого «сначала взять пять, потом отсортировать» не бывает.
select колонки
from таблица
[where ...] [group by ...] [having ...]
order by выражение1 [asc | desc] [nulls first | nulls last],
выражение2 ...
limit n [offset m];
select plan, sum(amount) as revenue
from payments
group by plan
order by revenue desc;
-- basic 13414, pro 11687, team 5538Как отсортировать по нескольким колонкам
Колонки в ORDER BY работают как в телефонной книге: сначала по первой, среди равных по первой — по второй, и так далее. Направление указывается у каждой колонки: order by paid_at desc, payment_id desc значит «сначала свежие дни, внутри дня — большие номера». Если написать desc один раз в конце, он относится только к последней колонке, а первая отсортируется по возрастанию.
Порядок, которого нет в данных, задают выражением. Тарифы по алфавиту идут basic, pro, team, а в отчёте их хотят видеть от дорогого к дешёвому. CASE в ORDER BY превращает названия в номера: team — 1, pro — 2, остальное — 3.
select plan, count(*) as payments
from payments
group by plan
order by case plan
when 'team' then 1
when 'pro' then 2
else 3
end;Сортировка по алиасу и по номеру колонки
Алиас из SELECT в ORDER BY работает везде: order by revenue desc выше — пример. Но только как самостоятельное имя. Выражение с алиасом, например order by revenue / payers desc, PostgreSQL не принимает: «column "revenue" does not exist». DuckDB такой запрос выполняет. Для переносимого кода повторите выражение целиком или вынесите расчёт в CTE.
Сортировка по номеру колонки — order by 2 desc — короче, но хрупкая. Номер ссылается на позицию в SELECT, а не на смысл. Стоит коллеге вставить новую колонку вторым столбцом, и запрос без ошибки начинает сортировать по ней. Ниже один и тот же order by 2 desc до и после такой правки: первые три места заняли другие каналы.
Номер удобен в разовом запросе в консоли. В сохранённых отчётах и дашбордах пишите имя или алиас.
| Было: 2-я колонка users | users | Стало: 2-я колонка mobile_pct | mobile_pct |
|---|---|---|---|
| organic | 1747 | paid_search | 66,0 |
| referral | 1037 | organic | 45,4 |
| paid_search | 1015 | referral | 42,1 |
| partner | 814 | partner | 40,3 |
Почему ORDER BY с DISTINCT даёт ошибку
select distinct channel from users order by signup_date в PostgreSQL падает с ошибкой «for SELECT DISTINCT, ORDER BY expressions must appear in select list». Логика простая: после DISTINCT у канала organic осталась одна строка, а дат регистрации у него сотни. По какой из них сортировать, непонятно.
DuckDB этот запрос выполняет и возвращает четыре канала в каком-то порядке, но смысла в этом порядке нет. Если нужно «каналы по дате первой регистрации», скажите это явно: group by channel order by min(signup_date).
NULLS FIRST и NULLS LAST: где окажутся пустые значения
У 55 пользователей не указана страна. Где окажется эта группа, зависит от базы и направления сортировки, и здесь PostgreSQL и DuckDB расходятся. PostgreSQL считает NULL больше любого значения: при ASC пустая строка уходит в конец, при DESC — в начало. DuckDB по умолчанию ставит NULL в конец в обоих направлениях.
На практике это значит, что запрос «страны по убыванию», проверенный в песочнице, в PostgreSQL покажет первой строкой пустую страну. В отчёте с LIMIT пустая строка может занять одно из немногих мест. Добавьте nulls last или nulls first явно: оба движка понимают эту запись и дают одинаковый результат. Как та же разница ломает рейтинги с ROW_NUMBER, разобрано в статье про RANK и DENSE_RANK.
| Сортировка | PostgreSQL 14 | DuckDB (песочница) |
|---|---|---|
| order by country | NULL последним | NULL последним |
| order by country desc | NULL первым | NULL последним |
| order by country desc nulls last | NULL последним | NULL последним |
| order by country nulls first | NULL первым | NULL первым |
LIMIT, OFFSET и FETCH FIRST
LIMIT n возвращает не больше n строк, OFFSET m пропускает первые m. Стандарт SQL записывает то же самое как offset m rows fetch first n rows only, и эту форму понимают и PostgreSQL, и DuckDB. В SQL Server вместо LIMIT исторически пишут top n.
У стандартной формы есть вариант fetch first 3 rows with ties: он возвращает первые три строки и все строки, равные третьей по ключу сортировки. В PostgreSQL (с 13-й версии) такой запрос по amount desc вернул 142 строки: все платежи по 39. DuckDB эту запись не поддерживает и отвечает ошибкой разбора. Если нужны «все, кто делит третье место» и в DuckDB, используйте RANK — это задача оконных функций.
LIMIT без ORDER BY возвращает произвольные строки. Для того чтобы посмотреть на данные глазами, это нормально. Для всего остального — нет.
Почему LIMIT возвращает разные строки при одинаковых значениях
Сумм платежей в учебной базе всего три: 39 у 142 платежей, 29 у 403, 19 у 706. Запрос order by amount desc limit 5 вернёт пять платежей по 39, но какие именно из 142 — стандарт не определяет. PostgreSQL вернул 35, 38, 4, 24, 43, DuckDB — 4, 24, 35, 38, 43. Та же пятёрка в другом порядке, и это ещё удачный случай.
Неудачный — постраничный вывод. В PostgreSQL для первой страницы планировщик держит верхушку из 5 строк (top-N heapsort), для второй — из 10, и равные строки в них оказываются в разном порядке. В результате на второй странице повторились платежи 4, 24 и 38, а на третьей — снова 24. DuckDB ошибся меньше, но тоже: 24 попал на первую и вторую страницы, 82 — на вторую и третью. За три страницы PostgreSQL показал 15 строк, но только 11 разных платежей, DuckDB — 13. Платежи 44 и 72, которые при уникальном ключе стоят на второй странице, в выдаче PostgreSQL не появились вовсе.
Лечение одно: последним ключом в ORDER BY ставить уникальную колонку. С order by amount desc, payment_id вторая страница в обоих движках — 44, 72, 80, 82, 92, без повторов. Уникальный ключ нужен и для воспроизводимости: отчёт, собранный завтра, покажет тех же людей, если данные не менялись.
| Страница | PostgreSQL | DuckDB | С ключом payment_id (оба) |
|---|---|---|---|
| 1 | 35, 38, 4, 24, 43 | 4, 24, 35, 38, 43 | 4, 24, 35, 38, 43 |
| 2 | 80, 4, 24, 38, 92 | 24, 72, 80, 82, 92 | 44, 72, 80, 82, 92 |
| 3 | 24, 82, 100, 101, 129 | 44, 82, 101, 107, 129 | 100, 101, 107, 124, 129 |
Постраничный вывод: OFFSET или keyset
Даже с уникальным ключом у OFFSET остаются две проблемы. Первая — скорость: чтобы отдать строки с 500 001-й по 500 020-ю, база должна найти и отбросить первые 500 000. Вторая — сдвиг: если между запросами страниц добавилась новая запись, всё уезжает на одну позицию, и одна строка показывается дважды.
Keyset-пагинация (её ещё называют пагинацией по курсору) запоминает ключ последней показанной строки и просит следующие строки после него. Последняя строка первой страницы в выгрузке платежей по дате — (2026-08-30, 1247). Следующая страница начинается с условия (paid_at, payment_id) < (date '2026-08-30', 1247) и возвращает платежи 1246–1242. Сравнение строк целиком работает и в PostgreSQL, и в песочнице.
Разницу в скорости мы замерили на ноутбуке в PostgreSQL 14 на сгенерированной таблице в миллион строк с индексом по (paid_at, payment_id). Цифры иллюстративные, но порядок показателен. OFFSET 500000 прошёл по индексу 500 020 строк и занял 86–111 мс. Keyset для той же страницы прочитал 20 строк за сотые доли миллисекунды. Та же логика, записанная через OR — paid_at < ... or (paid_at = ... and payment_id < ...) — индексным условием не стала: база отбросила фильтром 500 000 строк и потратила 88 мс. Пишите сравнение строк, а не его развёрнутую форму.
У keyset есть цена: нельзя сразу перейти на страницу 37, только «следующая» и «предыдущая». Для выгрузок, лент и API это обычно устраивает. Для таблицы с номерами страниц OFFSET остаётся, но с уникальным ключом и без надежды на скорость на глубоких страницах.
select payment_id, user_id, paid_at, amount
from payments
where (paid_at, payment_id) < (date '2026-08-30', 1247)
order by paid_at desc, payment_id desc
limit 5;Топ-N в каждой группе: почему LIMIT не подходит
LIMIT обрезает весь результат целиком и ничего не знает о группах. «Три самых дорогих платежа в каждом месяце» через один LIMIT не получить: он вернёт три строки на весь запрос, и все они могут оказаться из одного месяца. Для этой задачи нужны ROW_NUMBER, RANK или DENSE_RANK с PARTITION BY, либо LATERAL JOIN с LIMIT внутри. Оба способа разобраны в отдельных статьях.
Гарантирует ли ORDER BY в подзапросе порядок результата
Нет. Порядок гарантирует только ORDER BY самого внешнего запроса. Сортировка внутри подзапроса или CTE — это подсказка, которую следующий шаг вправе проигнорировать.
Пример: в подзапросе пользователи отсортированы по дате последнего платежа, первыми идут 532, 641, 1618 с платежами 30 августа. Затем результат соединяется с users, чтобы добавить канал, и наружу выходят 3, 6, 8, 21, 25 — в порядке таблицы пользователей. PostgreSQL честно выполнил сортировку внутри, а потом построил Hash Join, который читает строки в своём порядке. DuckDB вернул те же пять строк.
Если порядок важен, ORDER BY ставится в последний SELECT. Исключение одно: ORDER BY вместе с LIMIT в подзапросе меняет не порядок, а состав строк, и там он обязателен.
select u.user_id, u.channel, t.last_paid
from (
select user_id, max(paid_at) as last_paid
from payments
group by user_id
order by last_paid desc, user_id -- не влияет на итог
) as t
join users as u on u.user_id = t.user_id
limit 5;
-- 3, 6, 8, 21, 25 — а не 532, 641, 1618 ...Частые ошибки с ORDER BY и LIMIT
- LIMIT без ORDER BY в отчёте. Строки случайные, и завтра будут другие.
- Сортировка только по метрике с повторами. Топ и постраничный вывод нестабильны, строки дублируются между страницами.
descодин раз в конце списка колонок. Он относится только к последней колонке.- NULL по умолчанию. Запрос из песочницы в PostgreSQL выводит пустые значения первыми при DESC.
order by 2в сохранённом запросе. Ломается молча при правке SELECT.- Алиас внутри выражения в ORDER BY. Работает в DuckDB, падает в PostgreSQL.
- ORDER BY в подзапросе вместо внешнего. Порядок теряется после JOIN или GROUP BY.
- Глубокий OFFSET на большой таблице. База читает всё, что пропускает.
Чеклист сортировки
- Последний ключ в ORDER BY уникален:
payment_id,user_id,event_id. - Для колонок с NULL явно указано
nulls firstилиnulls last. - Направление указано у каждой колонки, где оно не ASC.
- ORDER BY стоит во внешнем запросе.
- Для глубоких страниц используется keyset, а условие записано сравнением строк.
- Топ-N по группам решается оконной функцией, а не LIMIT.
Проверьте на учебной базе
В песочнице SQL-курса выполните select payment_id, amount from payments order by amount desc limit 5 offset 5, а затем тот же запрос с , payment_id в ORDER BY. Сравните вторые страницы и найдите платёж, который без второго ключа попал на две страницы сразу. Затем отсортируйте страны по убыванию и проверьте, где окажется пустая страна с nulls first и без него.
Материалы по теме

UPDATE в SQL: синтаксис, примеры и обновление по другой таблице
UPDATE в SQL и PostgreSQL: обновление одного и нескольких полей, CASE, UPDATE … FROM по другой таблице, RETURNING и безопасный порядок через транзакцию.

DELETE в SQL: как удалить строки и не потерять лишнее
DELETE в SQL и PostgreSQL: удаление по условию, DELETE … USING, удаление дублей, RETURNING, разница DELETE, TRUNCATE и DROP, внешние ключи.

INSERT INTO в SQL: вставка строк, INSERT SELECT и RETURNING
INSERT INTO в SQL и PostgreSQL: вставка нескольких строк, DEFAULT и identity, INSERT … SELECT для витрины, RETURNING и частые ошибки.