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

MAX в SQL: строка с максимальным значением, первая и последняя запись

MAX в SQL возвращает значение, а не строку. Способы получить строку с максимумом в группе: подзапрос, NOT EXISTS, ROW_NUMBER, DISTINCT ON, LATERAL, arg_max. Ничьи и NULL.

КейсПрактика2 октября 2026 г.13 мин

SELECT MAX(amount) FROM payments возвращает одно значение — наибольшее в столбце (в учебной базе 39.00); с GROUP BY user_id — по одному на группу. Остальных полей строки в ответе нет. Строку с максимумом целиком отбирают сравнением: WHERE amount = (SELECT MAX(amount) FROM payments). Первую или последнюю запись таблицы — сортировкой: ORDER BY paid_at DESC, payment_id DESC LIMIT 1. Последнюю запись в каждой группе — нумерацией: ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY paid_at DESC, payment_id DESC) и фильтр по первому номеру. Ниже — что эти и другие записи возвращают при ничьей, куда попадают NULL и чем различаются планы. Запросы выполнены на учебной базе в PostgreSQL 14 и DuckDB.

Как получить строку с максимальным значением: короткий ответ

SELECT MAX(event_time) FROM events вернёт 2026-08-30 12:59 и больше ничего: кто это был и что сделал, в ответе нет. Самый переносимый способ достать строку — сравнить столбец с максимумом из подзапроса: он работает в любой СУБД без оконных функций.

Запрос ниже вернул одну строку: событие 35 341, пользователь 3 172, app_open. Одну, потому что максимальное время в таблице не повторяется. Тот же приём с MAX(amount) по оплатам отдаёт 142 строки: наибольшая сумма, $39, повторяется 142 раза.

Когда максимум нужен в каждой группе — последняя оплата каждого плательщика, — строки нумеруют внутри группы и оставляют первую. Второй запрос возвращает 912 строк, по одной на плательщика. Оконные функции есть во всех распространённых СУБД, в MySQL — с версии 8.0. Для MIN и первой записи всё зеркально: меняется направление сортировки.

Первые пять строк из 912: последняя оплата со всеми полями
user_idpayment_idpaid_atamountplanpayment_type
3262026-06-1529.00profirst
63442026-07-1739.00teamrenewal
87632026-08-0929.00prorenewal
218062026-08-1119.00basicrenewal
253252026-07-1519.00basicrenewal
Последнее событие в таблице: строка целиком
SELECT event_id, user_id, event_time, event_name
FROM events
WHERE event_time = (SELECT MAX(event_time) FROM events);
sqlПоследняя оплата каждого плательщика: ROW_NUMBER
SELECT user_id, payment_id, paid_at, amount, plan, payment_type
FROM (
  SELECT p.*,
         ROW_NUMBER() OVER (
           PARTITION BY user_id
           ORDER BY paid_at DESC, payment_id DESC
         ) AS rn
  FROM payments AS p
) AS numbered
WHERE rn = 1
ORDER BY user_id
LIMIT 5;

Почему нельзя дописать столбцы рядом с MAX

Первая попытка: SELECT user_id, MAX(paid_at), plan FROM payments GROUP BY user_id. PostgreSQL отвечает ошибкой column "payments.plan" must appear in the GROUP BY clause or be used in an aggregate function: в группе несколько строк, и база не знает, из какой взять тариф.

Дальше два ложных выхода. Добавить столбец в GROUP BY — изменить то, что означает одна строка результата. С plan в учебной базе это случайно сходит с рук: тариф у пользователя один, строк остаётся 912. С payment_type запрос вернёт 1 202 строки вместо 912. Обернуть каждый столбец в свой MAX — собрать строку, которой в таблице нет: агрегаты ищут максимумы независимо друг от друга.

Пример — выручка по дням. В августе MAX(revenue) равен $771, MAX(payments) — 30. Но $771 заработаны 22 августа на 29 оплатах, а по 30 оплат было 9 и 25 августа. Дня «771 доллар и 30 оплат» не существует. В июне и июле оба максимума пришлись на один день, и ошибку там не заметить. Правильный порядок — сначала агрегат до дня, потом выбор строки внутри месяца.

Третья попытка — сравнить с агрегатом прямо в условии: WHERE amount = MAX(amount). PostgreSQL ответит aggregate functions are not allowed in WHERE. Агрегат считается после отбора строк, поэтому максимум выносят в подзапрос.

Независимые максимумы и настоящий лучший день месяца
МесяцMAX(revenue)MAX(payments)День с максимальной выручкойОплат в этот день
Июнь3751530 июня15
Июль6322828 июля28
Август7713022 августа29
Так не надо: два независимых максимума в одной строке
WITH daily AS (
  SELECT paid_at, SUM(amount) AS revenue, COUNT(*) AS payments
  FROM payments
  GROUP BY paid_at
)
SELECT CAST(date_trunc('month', paid_at) AS date) AS month,
       MAX(revenue) AS max_revenue,
       MAX(payments) AS max_payments
FROM daily
GROUP BY 1
ORDER BY 1;
sqlДень с максимальной выручкой в каждом месяце
WITH daily AS (
  SELECT paid_at, SUM(amount) AS revenue, COUNT(*) AS payments
  FROM payments
  GROUP BY paid_at
),
numbered AS (
  SELECT paid_at, revenue, payments,
         ROW_NUMBER() OVER (
           PARTITION BY date_trunc('month', paid_at)
           ORDER BY revenue DESC, paid_at
         ) AS rn
  FROM daily
)
SELECT paid_at, revenue, payments
FROM numbered
WHERE rn = 1
ORDER BY paid_at;

Как найти строку с максимумом без оконных функций: JOIN с агрегатом и NOT EXISTS

Классический способ — два шага: подзапрос считает максимум в каждой группе, внешний запрос присоединяет к нему исходную таблицу по ключу группы и по самому значению. Те же строки даёт коррелированный подзапрос WHERE p.paid_at = (SELECT MAX(x.paid_at) FROM payments AS x WHERE x.user_id = p.user_id); как он выполняется, разобрано в статье о коррелированных подзапросах.

NOT EXISTS формулирует условие от обратного: оставить оплату, позже которой у того же пользователя ничего нет. Оба запроса вернули те же 912 строк, что и ROW_NUMBER, с совпадением всех полей. Совпали они благодаря данным: у одного пользователя в учебной базе нет двух оплат в один день.

Последняя оплата: JOIN с подзапросом-агрегатом
SELECT p.user_id, p.payment_id, p.paid_at, p.amount, p.plan, p.payment_type
FROM payments AS p
JOIN (
  SELECT user_id, MAX(paid_at) AS last_paid
  FROM payments
  GROUP BY user_id
) AS m
  ON m.user_id = p.user_id
 AND m.last_paid = p.paid_at
ORDER BY p.user_id
LIMIT 5;
sqlПоследняя оплата: NOT EXISTS — «позже ничего нет»
SELECT p.user_id, p.payment_id, p.paid_at, p.amount, p.plan, p.payment_type
FROM payments AS p
WHERE NOT EXISTS (
  SELECT 1
  FROM payments AS later
  WHERE later.user_id = p.user_id
    AND later.paid_at > p.paid_at
)
ORDER BY p.user_id
LIMIT 5;

Как записать то же короче: DISTINCT ON, LATERAL и arg_max

DISTINCT ON оставляет из каждой группы первую строку в порядке ORDER BY: SELECT DISTINCT ON (user_id) * FROM payments ORDER BY user_id, paid_at DESC, payment_id DESC. Это расширение PostgreSQL, которое понимает и DuckDB; в PostgreSQL сортировка обязана начинаться с выражений из DISTINCT ON — подробности в разборе DISTINCT. LATERAL идёт от списка групп и для каждого пользователя берёт одну строку подзапросом: SELECT l.* FROM users AS u CROSS JOIN LATERAL (SELECT * FROM payments AS p WHERE p.user_id = u.user_id ORDER BY p.paid_at DESC, p.payment_id DESC LIMIT 1) AS l. Выбор между CROSS и LEFT и N строк на группу — в статье про LATERAL. Обе записи вернули те же 912 строк.

В DuckDB есть агрегат arg_max(arg, val), он же max_by: возвращает arg из строки, где val максимален, без подзапроса и окна. В ClickHouse такой агрегат называется argMax. В PostgreSQL 14 этой функции нет: запрос падает с function arg_max(integer, date) does not exist.

Переносимая замена — собрать значения группы в массив в нужном порядке и взять первый элемент. Скобки вокруг array_agg(…) обязательны: без них PostgreSQL отвечает syntax error at or near "[". Элементы массива нумеруются с единицы в обеих базах. Результат совпал с arg_max на всех 912 строках. Совпадение держится, пока в данных нет NULL: массив пустые значения сохраняет, а при пропусках в ключе сортировки нужен DESC NULLS LAST, иначе PostgreSQL возьмёт строку с NULL.

У arg_max две особенности. По документации DuckDB он пропускает строки, где arg или val равны NULL: если в строке с максимальной датой тариф пуст, вернётся тариф из другой строки. А второй ключ сортировки ему передают составным значением, arg_max(payment_id, (amount, payment_id)), — так из равных берётся оплата с большим payment_id. Без него выбор между равными документация не определяет.

DuckDB: arg_max — значение из строки с максимальной датой
SELECT user_id,
       MAX(paid_at) AS paid_at,
       arg_max(payment_id, paid_at) AS payment_id,
       arg_max(plan, paid_at) AS plan,
       arg_max(payment_type, paid_at) AS payment_type
FROM payments
GROUP BY user_id
ORDER BY user_id
LIMIT 5;
sqlТо же в PostgreSQL и DuckDB: первый элемент отсортированного массива
SELECT user_id,
       MAX(paid_at) AS paid_at,
       (array_agg(payment_id ORDER BY paid_at DESC, payment_id DESC))[1] AS payment_id,
       (array_agg(plan ORDER BY paid_at DESC, payment_id DESC))[1] AS plan,
       (array_agg(payment_type ORDER BY paid_at DESC, payment_id DESC))[1] AS payment_type
FROM payments
GROUP BY user_id
ORDER BY user_id
LIMIT 5;

Что вернёт запрос, если максимум не один: ничьи в SQL и pandas

Задача: самая крупная оплата каждого пользователя с датой и тарифом. В учебной базе тариф пользователя не меняется, и все его оплаты равны: у пользователя 8 их три по $29 — 10 июня, 10 июля и 9 августа.

Способы делятся на две группы. JOIN с агрегатом, коррелированный подзапрос, NOT EXISTS и RANK() = 1 возвращают все строки с максимумом: 1 251 строку на 912 пользователей — всю таблицу. ROW_NUMBER, DISTINCT ON, LATERAL … LIMIT 1 и arg_max — по одной строке на группу: 912.

Во второй группе неясно, какую из равных строк база возьмёт. С сортировкой только по amount DESC ответ перестаёт быть воспроизводимым. В нашем прогоне PostgreSQL 14 и DuckDB 1.4 выбрали через ROW_NUMBER разные оплаты примерно у половины из 290 пользователей с повторными оплатами. А сам PostgreSQL после SET work_mem = '64kB' выбрал не те строки, что с настройкой по умолчанию. Точное число расхождений зависит от версии, плана и настроек.

Выбор становится однозначным со вторым ключом сортировки, доведённым до уникального: ORDER BY amount DESC, paid_at, payment_id — самая ранняя из крупнейших. С ним обе базы вернули одни и те же 912 строк. Как выбирать такой ключ по смыслу — в статье про RANK и DENSE_RANK.

Первая группа опасна в отчёте, который ждёт одну строку на пользователя: сумма по 1 251 строке — $30 639, по 912 — $22 328. Если нужны все строки с максимумом, пишите RANK() = 1 и показывайте число строк рядом с числом групп.

Ничьи появляются и после агрегации: в августе по 30 оплат прошло и 9-го, и 25-го числа. В pandas те же две группы. Сравнение с transform('max') возвращает четыре дня на три месяца: 30 июня, 28 июля, 9 и 25 августа. idxmax возвращает три: при ничьей он берёт первое вхождение, 9 августа. sort_values('payments', ascending=False).drop_duplicates('month') тоже дал три строки, но для августа — 25-е число. Сортировка по одному столбцу по умолчанию идёт алгоритмом quicksort, а он нестабилен: порядок равных строк не гарантирован. С kind='stable' тот же код возвращает 9 августа. Надёжнее дать второй ключ сортировки: после него годятся и drop_duplicates, и groupby().head(1).

Пропуски sort_values ставит в конец при любом направлении, как DuckDB, а idxmax их пропускает. Для группы из одних пропусков groupby().idxmax() в pandas 2.3 возвращает NaN, в pandas 3.0 — ошибку ValueError.

Строк в ответе: самая крупная оплата каждого из 912 пользователей

1 251 — вся таблица payments: тариф у пользователя не меняется, поэтому максимальна каждая его оплата. В данных с разными суммами лишних строк будет меньше. У 290 пользователей две или три оплаты. arg_max выполнен только в DuckDB.

Строк
Самая крупная оплата пользователя: сколько строк вернёт каждый подход
WITH biggest AS (
  SELECT user_id, MAX(amount) AS max_amount
  FROM payments
  GROUP BY user_id
),
ranked AS (
  SELECT user_id,
         RANK() OVER (PARTITION BY user_id ORDER BY amount DESC) AS place,
         ROW_NUMBER() OVER (
           PARTITION BY user_id
           ORDER BY amount DESC, paid_at, payment_id
         ) AS rn
  FROM payments
)
SELECT
  (SELECT COUNT(*) FROM biggest) AS users,
  (SELECT COUNT(*)
   FROM payments AS p
   JOIN biggest AS b
     ON b.user_id = p.user_id AND b.max_amount = p.amount) AS join_rows,
  (SELECT COUNT(*) FROM ranked WHERE place = 1) AS rank_rows,
  (SELECT COUNT(*) FROM ranked WHERE rn = 1) AS row_number_rows;
pythonpandas: четыре способа и три разных ответа на ничью
import pandas as pd

# число оплат по дням с 1 июня по 30 августа 2026 (COUNT(*) по paid_at, дни без оплат — нули)
payments = [
    0, 0, 0, 0, 0, 2, 1, 3, 0, 2, 0, 5, 7, 5, 4, 8, 8, 5, 8, 3, 12, 10, 9, 9, 8, 14, 6, 10, 13, 15,
    8, 13, 13, 8, 4, 16, 11, 7, 13, 15, 8, 12, 14, 15, 7, 12, 12, 15, 21, 11, 17, 19, 19, 13, 14, 23,
    16, 28, 19, 24, 15,
    20, 16, 15, 23, 20, 20, 21, 18, 30, 13, 20, 20, 22, 14, 19, 20, 23, 14, 22, 18, 29, 29, 22, 21,
    30, 21, 26, 24, 23, 29,
]
daily = pd.DataFrame({'day': pd.date_range('2026-06-01', '2026-08-30'), 'payments': payments})
daily['month'] = daily.day.dt.month
fmt = lambda frame: sorted(frame.day.dt.strftime('%m-%d'))

month_max = daily.groupby('month').payments.transform('max')
all_ties = daily[daily.payments == month_max]                       # как RANK() = 1
first_hit = daily.loc[daily.groupby('month').payments.idxmax()]     # первое вхождение
one_key = daily.sort_values('payments', ascending=False).drop_duplicates('month')
two_keys = daily.sort_values(['payments', 'day'], ascending=[False, True]).groupby('month').head(1)

print(len(all_ties), fmt(all_ties))    # 4 ['06-30', '07-28', '08-09', '08-25']
print(len(first_hit), fmt(first_hit))  # 3 ['06-30', '07-28', '08-09']
print(len(one_key), fmt(one_key))      # 3 ['06-30', '07-28', '08-25'] — порядок равных не гарантирован
print(len(two_keys), fmt(two_keys))    # 3 ['06-30', '07-28', '08-09']

Как выбрать первую и последнюю запись: в таблице и у каждого пользователя

У таблицы нет порядка строк. «Последняя запись» существует только относительно столбца: времени, даты, возрастающего идентификатора. Без ORDER BY порядок, по документации PostgreSQL, не определён: он зависит от плана выполнения и расположения строк на диске.

Проверка на временной копии payments в PostgreSQL 14. Сразу после создания SELECT … LIMIT 1 вернул оплату 1. После UPDATE одной этой строки, без изменения значений, тот же запрос вернул оплату 2: новую версию строки PostgreSQL записал на свободное место, в этом опыте — в конец таблицы. Данные прежние, «первая запись» другая. Это наблюдение, а не правило: такой же UPDATE последней строки порядок не изменил. Рассчитывать нельзя ни на прежний порядок, ни на новый.

Поэтому первая и последняя запись — это ORDER BY по столбцу со смыслом плюс уникальный ключ. Самая поздняя дата paid_at стоит в 29 оплатах, и без payment_id выбор между ними не определён. Синтаксис LIMIT, FETCH FIRST и TOP в разных СУБД — в статье про ORDER BY и LIMIT.

Первая и последняя запись в группе — одна задача с двумя направлениями сортировки. Во втором запросе две нумерации помечают края: first_no = 1 — первое событие пользователя, last_no = 1 — последнее. Условный агрегат сворачивает краевые строки в одну на пользователя.

У всех 4 613 пользователей первое событие — app_open; последнее у 4 432 из них (96,1%) тоже app_open. У 73 человек событие единственное, и одна строка служит и первой, и последней: краевых строк 9 153, а не 4 613 × 2 = 9 226. «Последнее» значит последнее на момент выгрузки, 30 августа в 13:00: у недавно пришедших оно ещё сменится.

Последнее событие пользователя на момент выгрузки
last_eventПользователейИз них с единственным событием
app_open4 43273
invite_sent680
export_completed650
report_created390
workspace_created90
Первая и последняя оплата в таблице: порядок задан явно
(SELECT 'первая' AS which_row, payment_id, user_id, paid_at
 FROM payments
 ORDER BY paid_at, payment_id
 LIMIT 1)
UNION ALL
(SELECT 'последняя', payment_id, user_id, paid_at
 FROM payments
 ORDER BY paid_at DESC, payment_id DESC
 LIMIT 1)
ORDER BY payment_id;
sqlПервое и последнее событие каждого пользователя: каким было последнее
WITH numbered AS (
  SELECT user_id, event_name, event_time,
         ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY event_time, event_id) AS first_no,
         ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY event_time DESC, event_id DESC) AS last_no
  FROM events
),
edges AS (
  SELECT user_id,
         MAX(CASE WHEN first_no = 1 THEN event_name END) AS first_event,
         MIN(event_time) AS first_time,
         MAX(CASE WHEN last_no = 1 THEN event_name END) AS last_event,
         MAX(event_time) AS last_time
  FROM numbered
  WHERE first_no = 1 OR last_no = 1
  GROUP BY user_id
)
SELECT last_event,
       COUNT(*) AS users,
       COUNT(*) FILTER (WHERE first_time = last_time) AS single_event
FROM edges
GROUP BY last_event
ORDER BY users DESC, last_event;

Куда попадают NULL при поиске максимума

MAX и MIN пропускают NULL и возвращают его, только когда в группе нет ни одного значения. Сортировка NULL не пропускает: PostgreSQL при DESC ставит их первыми, DuckDB — последними, поэтому в окне пишут DESC NULLS LAST (разбор на рейтингах).

Пример — пользователь с самой поздней оплатой в каждом канале, от users через LEFT JOIN: у 3 701 человека оплат нет, дата у них NULL. Без NULLS LAST PostgreSQL вернул четырёх человек без единой оплаты — пользователей 4, 1044, 1 и 2, — а DuckDB плативших 30 августа. С NULLS LAST ответ в обеих базах один.

DISTINCT ON и LATERAL … LIMIT 1 в PostgreSQL точно так же поставят строку с NULL первой. А JOIN с агрегатом потеряет группу, где все значения NULL: MAX вернёт NULL, и равенство с ним не выполняется.

Пользователь с самой поздней оплатой в каждом канале; из плативших в один день — с меньшим user_id
WITH last_pay AS (
  SELECT user_id, MAX(paid_at) AS last_paid
  FROM payments
  GROUP BY user_id
),
numbered AS (
  SELECT u.channel, u.user_id, l.last_paid,
         ROW_NUMBER() OVER (
           PARTITION BY u.channel
           ORDER BY l.last_paid DESC NULLS LAST, u.user_id
         ) AS rn
  FROM users AS u
  LEFT JOIN last_pay AS l ON l.user_id = u.user_id
)
SELECT channel, user_id, last_paid
FROM numbered
WHERE rn = 1
ORDER BY channel;

Максимум из нескольких столбцов: GREATEST, даты и строки

MAX сравнивает строки одного столбца. Максимум из нескольких столбцов одной строки — это GREATEST: например, дата последней активности как большая из дат последнего события и последней оплаты. В PostgreSQL и DuckDB GREATEST пропускает NULL: у 3 701 неплательщика дата оплаты пуста, а результат заполнен у всех 4 613. У 240 плательщиков последняя оплата позже последнего события: по одним событиям дата их активности была бы занижена. По документации MySQL там GREATEST возвращает NULL, если хотя бы один аргумент NULL, и понадобится COALESCE.

С датами и строками MAX работает как с числами: самая поздняя дата, последняя по алфавиту строка. Отсюда ловушка с числами, записанными текстом: MAX(CAST(user_id AS varchar)) вернул 999, хотя MAX(user_id) — 4 613, потому что строки сравниваются посимвольно. Есть и различие движков: MAX по логическому столбцу DuckDB считает, а PostgreSQL 14 отвечает function max(boolean) does not exist — там пишут bool_or.

GREATEST: большая из двух дат в строке
WITH last_seen AS (
  SELECT u.user_id,
         CAST(MAX(e.event_time) AS date) AS last_event_day,
         (SELECT MAX(p.paid_at)
          FROM payments AS p
          WHERE p.user_id = u.user_id) AS last_paid
  FROM users AS u
  JOIN events AS e ON e.user_id = u.user_id
  GROUP BY u.user_id
)
SELECT COUNT(*) AS users,
       COUNT(last_paid) AS payers,
       COUNT(GREATEST(last_event_day, last_paid)) AS greatest_filled,
       COUNT(*) FILTER (WHERE last_paid > last_event_day) AS paid_after_last_event
FROM last_seen;

Какой способ быстрее

На учебной базе разницы нет: в payments 1 251 строка, и время здесь сравнивать бессмысленно. Сравнить можно планы: EXPLAIN ANALYZE в PostgreSQL 14 показывает, сколько раз способ читает таблицу.

Выделяются два способа с подзапросом на каждую строку: LATERAL без индекса прошёл payments 4 613 раз, по разу на пользователя, коррелированный подзапрос — 1 251 раз. С индексом по (user_id, paid_at DESC, payment_id DESC) на временной копии LATERAL делает поиск по индексу вместо прохода таблицы, а у ROW_NUMBER и DISTINCT ON из плана исчезает сортировка.

Ориентир: для одной строки со всеми полями в PostgreSQL и DuckDB короче всего DISTINCT ON, переносимо — ROW_NUMBER: одно чтение таблицы и сортировка, правило ничьей записано в коде. LATERAL оправдан, когда групп мало, строк в каждой много и под подзапрос есть индекс. Здесь это не измерялось — разбор на 300 000 платежей в статье про LATERAL. DuckDB такие подзапросы сам превращает в соединение, и цикла в его плане нет. На своих данных решает план; как его читать — в статьях про EXPLAIN ANALYZE и индексы.

Планы PostgreSQL 14 на учебной базе без индексов
СпособУзлы планаЧтений payments
JOIN с агрегатомHashAggregate, Hash Join2
NOT EXISTSHash Anti Join2
ROW_NUMBERSort, WindowAgg1
DISTINCT ONSort, Unique1
Коррелированный подзапросSubPlan на каждую строку1 + 1 251
LATERAL от usersNested Loop, Seq Scan в цикле4 613

Что читать дальше

Проверьте себя: найдите первую оплату каждого плательщика и посчитайте, сколько из них пришлось на июль. Ответ — 335 из 912 (в июне 167, в августе 410), одинаковый у ROW_NUMBER, JOIN с MIN(paid_at) и DISTINCT ON. Если получилось 204 — вы взяли последнюю оплату: перепутано направление сортировки. Запросы статьи выполняются в песочнице симулятора SQL-аналитика на этой же учебной базе.

Продолжить чтение
Вся библиотека
Продуктовая аналитика24 сентября 2026 г.13 мин
Поток одинаковых ячеек сжимается в короткий ряд уникальных значений.

DISTINCT в SQL: уникальные строки, COUNT(DISTINCT) и DISTINCT ON

Как работает DISTINCT в SQL на реальной учебной базе: уникальность по всей строке, DISTINCT по нескольким столбцам, COUNT(DISTINCT) для DAU, NULL, отличие от GROUP BY, DISTINCT ON в PostgreSQL и DuckDB, и почему DISTINCT после JOIN прячет ошибку в выручке.

Читать материал
Продуктовая аналитика8 августа 2026 г.13 мин
Каждая строка столбца раскрывается в собственную группу ячеек.

LATERAL JOIN в SQL: последняя запись, top-N на пользователя и функции в FROM

LATERAL JOIN на реальной учебной базе: последний платёж пользователя со всеми полями, разница между LEFT и CROSS, почему подзапрос с агрегатом никогда не отбрасывает строки, три последних события на человека, индекс, без которого LATERAL читает таблицу на каждой строке, и когда хватит GROUP BY, DISTINCT ON или окна.

Читать материал