Все материалы
Продуктовая аналитикагайдстарт

ORDER BY и LIMIT в SQL: сортировка, топ-N и постраничный вывод

ORDER BY и LIMIT в SQL: сортировка по нескольким колонкам, NULLS FIRST и LAST, LIMIT OFFSET, ничьи в топ-N и keyset-пагинация.

КейсПрактика25 сентября 2026 г.11 мин

Менеджер выгружает из внутренней админки список самых дорогих платежей постранично, по пять строк. На второй странице он находит три платежа, которые уже видел на первой, а несколько других не попадаются ему вовсе. Запрос простой: 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.

Свой порядок через выражение: team 142, pro 403, basic 706
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 до и после такой правки: первые три места заняли другие каналы.

Номер удобен в разовом запросе в консоли. В сохранённых отчётах и дашбордах пишите имя или алиас.

order by 2 desc до и после вставки колонки mobile_pct вторым столбцом
Было: 2-я колонка usersusersСтало: 2-я колонка mobile_pctmobile_pct
organic1747paid_search66,0
referral1037organic45,4
paid_search1015referral42,1
partner814partner40,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.

Где стоит NULL по умолчанию (проверено на users.country)
СортировкаPostgreSQL 14DuckDB (песочница)
order by countryNULL последнимNULL последним
order by country descNULL первымNULL последним
order by country desc nulls lastNULL последнимNULL последним
order by country nulls firstNULL первым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, без повторов. Уникальный ключ нужен и для воспроизводимости: отчёт, собранный завтра, покажет тех же людей, если данные не менялись.

order by amount desc limit 5 offset 0 / 5 / 10: payment_id на каждой странице
СтраницаPostgreSQLDuckDBС ключом payment_id (оба)
135, 38, 4, 24, 434, 24, 35, 38, 434, 24, 35, 38, 43
280, 4, 24, 38, 9224, 72, 80, 82, 9244, 72, 80, 82, 92
324, 82, 100, 101, 12944, 82, 101, 107, 129100, 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 остаётся, но с уникальным ключом и без надежды на скорость на глубоких страницах.

Вторая страница по курсору: платежи 1246–1242
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 в подзапросе меняет не порядок, а состав строк, и там он обязателен.

Сортировка внутри потерялась после JOIN
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 и без него.

Продолжить чтение
Вся библиотека