SQL с нуля: как работают SELECT, FROM и WHERE
Разбираем первый SQL-запрос на понятном примере: как выбрать колонки, указать таблицу, отфильтровать строки и не перепутать результат с данными продукта.
Содержание статьи
Первый SQL-запрос лучше учить не на абстрактных цифрах, а на вопросе из реальной работы: «Покажите пользователей, которые пришли из organic в июне». В нём уже есть всё необходимое — какие поля нужны, из какой таблицы их взять и какие строки оставить. Разберём эту логику на простом примере и закончим запросом, который можно выполнить в бесплатной песочнице симулятора SQL-аналитика.
Коротко
У базового запроса есть три главные детали: SELECT выбирает столбцы в результате, FROM указывает источник данных, а WHERE отбрасывает строки, которые не подходят под условие. Порядок написания всегда такой: сначала что показать, затем откуда взять, затем что оставить.
SELECT— какие поля или расчёты вернуть.FROM— из какой таблицы или представления читать данные.WHERE— какие строки подходят под вопрос.- Перед запросом нужно понять, что означает одна строка в таблице.
- Для предсказуемой выборки добавляйте
ORDER BY, а для короткого примера —LIMIT.
Порядок выполнения SQL-запроса
Запрос пишут с SELECT, а выполняется он с FROM. Логический порядок выполнения SQL-запроса — семь шагов: FROM и JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT. На пятом шаге вместе с выражениями SELECT считаются оконные функции, после них DISTINCT убирает повторы. Первые запросы обходятся тремя из этих шагов, но список стоит увидеть целиком: каждый следующий оператор встанет в нём на своё место.
Отсюда же ответ на частый вопрос, почему алиас (псевдоним) из SELECT нельзя использовать в WHERE: фильтр выполняется раньше, чем SELECT даёт колонке новое имя. Запрос SELECT signup_date AS registered FROM users WHERE registered >= DATE '2026-08-29' PostgreSQL отклоняет с ошибкой column "registered" does not exist; с исходным именем signup_date он возвращает 38 строк. DuckDB, на котором работает песочница, такой алиас допускает, поэтому запрос, проверенный там, может упасть после переноса в PostgreSQL.
| Шаг | Что делает | Что из этого следует |
|---|---|---|
| 1. FROM и JOIN | собирает строки из таблиц | остальные шаги работают с этим набором |
| 2. WHERE | оставляет строки, подходящие под условие | алиасов из SELECT и агрегатов здесь ещё нет |
| 3. GROUP BY | собирает строки в группы | дальше доступны поля группировки и агрегаты |
| 4. HAVING | оставляет группы, подходящие под условие | условие на count или sum пишут здесь |
| 5. SELECT | считает оконные функции и выражения, даёт колонкам алиасы, затем DISTINCT убирает повторы | окна видят строки, прошедшие шаги 1–4; DISTINCT сравнивает строки результата целиком |
| 6. ORDER BY | сортирует результат | алиасы из SELECT уже видны |
| 7. LIMIT | обрезает выдачу | без ORDER BY неизвестно, какие строки останутся |
Сначала разберитесь со структурой данных
Представим учебную таблицу users. В ней одна строка — один пользователь. Это важно зафиксировать до запроса: если одна строка уже описывает пользователя, можно читать список пользователей напрямую. В таблице events одна строка, наоборот, обычно означает одно событие, поэтому подсчёт строк там не равен подсчёту людей.
В песочнице симулятора SQL-аналитика схема открыта рядом с редактором: можно посмотреть типы колонок и сразу проверить запросы на полной учебной базе. Такой цикл полезнее, чем запоминать синтаксис отдельно от смысла таблиц.
| Колонка | Тип | Что означает |
|---|---|---|
| user_id | integer | уникальный идентификатор пользователя |
| signup_date | date | дата регистрации |
| channel | text | канал привлечения: organic, paid_search, partner или referral |
SELECT: что показать в результате
SELECT задаёт форму результата. Если нужно проверить пользователей и их каналы, перечислите только эти поля. Явный список колонок помогает читать запрос и не тащит в ответ случайные технические поля.
В таблице 4 613 строк, поэтому запрос заканчивается двумя строками для просмотра: order by user_id сортирует пользователей по номеру, limit 10 показывает первые десять.
select
user_id,
signup_date,
channel
from users
order by user_id
limit 10;- Не используйте
select *в итоговом отчёте: результат становится шире, а смысл — менее очевидным. - Порядок колонок в
SELECT— это порядок колонок в ответе. - Вместо поля можно вернуть выражение или вычисляемое значение, но сначала закрепите базовую выборку.
FROM: откуда читать строки
FROM users говорит базе, что источником результата является таблица users. Если у таблицы есть короткий псевдоним, запрос становится удобнее расширять: from users as u, а колонку можно написать как u.user_id.
Название таблицы само по себе ещё ничего не гарантирует. Перед расчётом проверьте, какая строка в ней хранится, за какой период загружены данные и нет ли отдельного представления для аналитики. Ошибка на этом шаге не исправляется красивым синтаксисом.
Спросите себя: если я возьму одну строку из users, она описывает пользователя, регистрацию, сессию или событие? Ответ определит, можно ли считать строки напрямую или понадобится distinct и агрегация.
WHERE: как оставить нужные строки
WHERE превращает общий список в ответ на конкретный вопрос. Например, так можно оставить только пользователей из organic, зарегистрированных в июне. Строковые значения пишутся в одинарных кавычках, а дату лучше сравнивать с датой, а не с произвольным текстом.
select
user_id,
signup_date,
channel
from users
where channel = 'organic'
and signup_date >= date '2026-06-01'
and signup_date < date '2026-07-01'
order by signup_date, user_id
limit 10; -- под условие подходят 478 строк, показаны первые десять=проверяет точное совпадение, напримерchannel = 'organic'.andтребует, чтобы совпали оба условия.orоставляет строку, если подходит хотя бы одно условие; группируйте такие условия скобками.- Для диапазона дат обычно удобно использовать левую включённую и правую исключённую границу.
Соберите запрос по шагам
Теперь объединим базовые части. Задача: посмотреть пять первых organic-пользователей, зарегистрированных в июне, в порядке даты регистрации. ORDER BY делает порядок явным, а LIMIT сокращает результат для быстрой проверки.
Читать его удобно сверху вниз как последовательность решений: какие поля вернуть → из какой таблицы → какие строки оставить → в каком порядке показать → сколько строк вывести. Логика выполнения внутри базы сложнее, но для аналитика такой порядок чтения хорошо сохраняет смысл запроса.
select
user_id,
signup_date,
channel
from users
where channel = 'organic'
and signup_date >= date '2026-06-01'
and signup_date < date '2026-07-01'
order by signup_date, user_id
limit 5;Частые ошибки в первых запросах
На старте проблема обычно не в том, что запрос не запускается. Гораздо опаснее запрос, который запускается и даёт ответ не на тот вопрос.
Назовите единицу строки, период, фильтры и ожидаемый размер ответа. Если не можете объяснить эти четыре пункта словами, SQL ещё рано отправлять в дашборд.
- Считать строки
eventsкак пользователей: одно и то же действие может повторяться много раз. - Писать
select *и не замечать, что в отчёт попали технические поля или персональные данные. - Забывать кавычки вокруг текста:
organic— значение, а не название колонки. - Использовать
limitбезorder by, а потом считать полученную пятёрку «первыми» пользователями. - Сравнивать дату регистрации со строкой в непонятном формате и надеяться на неявное приведение типов.
- Не проверять
null: условиеchannel = 'organic'не включает пользователей с пустым каналом.
Что проверить перед первым запуском
Проверяйте базовый запрос как маленькое исследование. Сначала выполните select count(*) from users, затем посмотрите диапазон signup_date и распределение channel. Это поможет понять, что фильтр не возвращает пустой результат из-за опечатки, другой кодировки или периода вне учебного набора.
Для выборки добавьте order by, иначе база не обязана возвращать строки в одинаковом порядке. limit полезен для осмотра, но не должен использоваться как часть метрики. Если нужно узнать число пользователей, уберите LIMIT и добавьте явную агрегацию.
Сравните несколько строк вручную с условием. Найдите одного organic-пользователя, зарегистрированного в июне, и одного, который должен быть исключён. Такой контроль связывает SQL с данными, а не только с зелёным статусом выполнения.
| Проверка | Что ловит |
|---|---|
| count(*) | пустой или неожиданно большой результат |
| min/max date | не тот период или формат даты |
| distinct channel | опечатку в значении фильтра |
| order by + limit | стабильный просмотр примера |
SELECT отвечает на вопрос, но не объясняет причину
Первая выборка — это наблюдение, а не вывод. Если вы нашли 478 organic-пользователей за июнь, следующий вопрос может быть про их активность, retention или конверсию в оплату. Не добавляйте JOIN и расчёты сразу: сначала убедитесь, что базовая аудитория определена правильно.
Сохраняйте запросы как маленькие шаги. Рабочий путь обычно выглядит так: посмотреть строки, проверить фильтр, посчитать группу, присоединить факт и сравнить сегменты. Такой порядок легче отлаживать и проще объяснить человеку, который только начинает SQL.
Когда результат станет частью отчёта, добавьте период, размер базы и ссылку на определение события. Тогда даже простой SELECT будет полезен команде, а не останется одноразовым упражнением.
Мини-практика
Попробуйте изменить последний запрос: оставьте только пользователей из referral, выведите десять строк и отсортируйте их по user_id. Затем замените диапазон дат на первые три дня июня. После этого можно переходить к агрегатам и считать, сколько пользователей пришло из каждого канала.
NULL: отсутствие значения — это не пустая строка
NULL означает, что значения нет или оно неизвестно. Это не то же самое, что пустая строка '', ноль или слово unknown. Сравнение channel = NULL не вернёт пользователей: для проверки отсутствия значения нужен IS NULL, а для наличия — IS NOT NULL.
В аналитике NULL часто имеет смысл. У пользователя ещё не определён канал, событие пришло без user_id, платёж не имеет даты возврата. Не удаляйте такие строки автоматически. Сначала решите, должны ли они входить в метрику, а затем явно назовите правило в описании расчёта.
| Значение | Что означает | Проверка |
|---|---|---|
| NULL | значение отсутствует или неизвестно | is null / is not null |
| '' | строка есть, но она пустая | channel = '' |
| 0 | числовое значение равно нулю | amount = 0 |
| unknown | явно записанная категория | channel = 'unknown' |
Даты: границы периода должны быть явными
Для месяца безопаснее использовать включённую нижнюю границу и исключённую верхнюю: signup_date >= date '2026-06-01' and signup_date < date '2026-07-01'. Так в запрос не нужно подставлять последний день месяца, а время 2026-06-30 23:59:59 не потеряется из-за округления.
Если поле хранит timestamp, уточните таймзону. «День» в UTC и день в часовом поясе продукта могут начинаться в разные моменты. Для DAU это способно менять число активных пользователей между датами. Правило преобразования должно быть одинаковым в отчёте, SQL и визуализации.
select
user_id,
signup_date
from users
where signup_date >= timestamp '2026-06-01 00:00:00'
and signup_date < timestamp '2026-07-01 00:00:00'
order by signup_date, user_id
limit 10; -- в июне 1042 регистрации, показаны первые десятьПеред публикацией напишите: «полные календарные дни в таймзоне продукта» или другое точное правило. Одного названия поля недостаточно.
AND, OR и скобки меняют аудиторию
Условие с AND оставляет строки, которые подходят под оба правила. OR оставляет строку, если совпало хотя бы одно. В выражении channel = 'organic' or channel = 'referral' and signup_date >= ... база сначала применит приоритет операторов, и результат может отличаться от того, что имел в виду аналитик.
Группируйте альтернативы скобками: (channel = 'organic' or channel = 'referral') and signup_date >= .... Скобки полезны не только для базы, но и для человека, который будет поддерживать запрос через месяц.
- Сначала выпишите каждое условие отдельной строкой.
- Скобками объедините альтернативные значения одного поля.
- Отдельно проверьте результат только с датой и только с каналом.
- Сравните количество строк до и после добавления каждого фильтра.
Как отлаживать запрос, который возвращает не то
Не переписывайте весь SQL, если результат странный. Уберите фильтры и проверьте базовый SELECT. Затем добавляйте условия по одному, каждый раз фиксируя размер выборки. Если строки исчезли после конкретного условия, проблема находится в значении, диапазоне, NULL или типе данных.
Для сложного вопроса сохраняйте промежуточные версии: аудитория без фильтров, аудитория после периода, аудитория после канала и финальный результат. Это простая ручная форма CTE: каждый шаг отвечает на один вопрос и позволяет объяснить расхождение.
| Шаг | Контрольный вопрос |
|---|---|
| 1. FROM | таблица существует и содержит ожидаемый период? |
| 2. SELECT | колонки имеют нужный тип и смысл? |
| 3. первый WHERE | фильтр возвращает хотя бы известный пример? |
| 4. следующий WHERE | сколько строк исчезло и почему? |
| 5. ORDER BY/LIMIT | просмотр стабилен и не влияет на метрику? |
База читает запрос не в том порядке, в каком вы его пишете
Запрос пишется как SELECT ... FROM ... WHERE ..., но логически всё происходит иначе: сначала база берёт строки из источника, затем отбрасывает не подходящие под условие, и только потом формирует колонки результата. Сортировка и ограничение выдачи применяются в самом конце, к уже готовому набору.
Из этого следует практическое правило, о которое спотыкаются почти все в первую неделю: псевдоним, придуманный в SELECT, обычно нельзя использовать в WHERE того же запроса. На момент фильтрации его ещё не существует. Если выражение нужно и для отбора, и для вывода, его либо повторяют, либо выносят на отдельный слой запроса.
Ещё одно следствие: LIMIT не делает запрос дешёвым. Он ограничивает то, что вы увидите, а не то, что база успела прочитать. На учебной базе это незаметно, на рабочей таблице событий — вполне ощутимо.
Из того же порядка растёт следующая тема курса. Когда появится группировка, окажется, что WHERE фильтрует отдельные строки до сборки групп, а условие по уже посчитанной сумме или количеству живёт в HAVING. Пока запрос не агрегирует, это различие не мешает — но помнить о нём стоит с первого дня.
ORDER BY видит псевдоним из SELECT, а WHERE — нет: это один список шагов
Посмотрите на два почти одинаковых запроса к events. Первый считает действия по дням: select date(event_time) as day, count(*) from events group by day order by day — выполняется. Во втором добавлено where day >= date '2026-06-01', и PostgreSQL отвечает column "day" does not exist. Имя одно и то же, запрос один и тот же, а group by и order by его находят, where и having — нет.
Объясняется это не списком исключений, а одним вопросом к каждому шагу: какие имена к этому моменту уже существуют. Выражения из select вычисляются на пятом шаге из семи, поэтому where и having про псевдоним ещё не знают, а сортировка на шестом шаге работает с готовым результатом и видит его спокойно. Отступление от порядка одно: group by выполняется раньше, но PostgreSQL и DuckDB разрешают писать в нём имя колонки результата.
Планировщик при этом не обязан ходить по семи шагам буквально: он может протащить условие внутрь соединения, взять строки из индекса вместо таблицы, поменять порядок обхода таблиц. Требование к нему одно — вернуть тот же результат, который дал бы буквальный обход. Отсюда практическая граница этого списка: он объясняет, почему база отказалась выполнять запрос или почему число получилось другим, но не почему запрос работает долго. Второй вопрос читается в explain, и там от написанного порядка операторов может не остаться почти ничего. Два способа из примеров ниже различаются так же: повтор выражения оставляет один шаг, слой с with добавляет второй — и каждый из них можно проверить отдельно.
| Шаг | Что делает | Какие имена уже существуют |
|---|---|---|
| 1. FROM / JOIN | берёт строки источников | колонки таблиц и их псевдонимы |
| 2. WHERE | выбрасывает строки | только колонки таблиц |
| 3. GROUP BY | собирает группы | колонки таблиц; PostgreSQL и DuckDB принимают и псевдонимы SELECT |
| 4. HAVING | выбрасывает группы | агрегаты и колонки группировки, но не псевдонимы |
| 5. SELECT | считает оконные функции поверх групп, вычисляет выражения, затем DISTINCT убирает повторы | здесь псевдонимы и появляются; окна видят результат шагов 1–4 |
| 6. ORDER BY | сортирует результат | колонки результата, псевдонимы, номера позиций |
| 7. LIMIT | обрезает выдачу | имена не нужны: только число строк |
-- в WHERE стоит колонка таблицы: с псевдонимом (where day >= ...) PostgreSQL
-- не запустится, а DuckDB в песочнице такой псевдоним допускает
select date(event_time) as day, count(*) as actions
from events
where event_time >= date '2026-06-01'
group by day
order by day;with daily as (
select user_id, date(event_time) as day
from events
)
select day, count(distinct user_id) as active_users
from daily
where day >= date '2026-06-01'
group by day
order by day;- Оконные функции считаются после группировки, поэтому
where row_number() over (...) = 1не запустится, а перенос условия вhavingне спасёт: там живут агрегаты групп, а не номера строк. Нужен отдельный слой. select distinct date(event_time) as day from events order by event_timeв PostgreSQL даёт ошибку: после удаления повторов строки с разным временем слиплись в одну, и сортировать стало не по чему.havingбезgroup byразрешён и считает всю выборку одной группой, так чтоhaving count(*) > 100вернёт либо весь результат, либо пустоту.
Одинаковые даты делают выдачу нестабильной, а OFFSET дорожает с каждой страницей
В собранном выше запросе сортировка записана как order by signup_date, user_id, и вторая колонка там не для симметрии. За 1–3 июня в базе 98 регистраций на три дня: 32, 33 и 33. Значит у десятков строк signup_date совпадает, и order by signup_date задаёт не порядок строк, а три группы; внутри группы база расставит строки как ей удобнее и на следующем запуске может расставить иначе.
Для просмотра это неважно, для выдачи по частям — ломает результат. limit 4 возьмёт четыре строки из 98, limit 4 offset 4 — следующие четыре. Если внутри дня порядок изменился, пользователь, стоявший четвёртым, уезжает на вторую страницу и появляется там второй раз, а кто-то другой не попадает ни в одну из выдач. Обе страницы выглядят нормально: ошибку видно только при сверке с общим числом строк. Лечится это тай-брейкером — колонкой, которая не повторяется: user_id подходит, channel или country нет, они тоже дают ничьи.
Вторая часть той же темы — цена листания. offset не пролистывает строки, а выбрасывает уже прочитанные: offset 100000 limit 20 обходится базе как выборка 100 020 строк, из которых сто тысяч отправляются в мусор. Устойчивая альтернатива — keyset: следующая страница просит не «пропусти сорок», а «дай то, что идёт после этой пары значений». Кортежное сравнение делает это одним условием и заодно защищает от сдвига выдачи, когда между запросами пришли новые регистрации; для сортировки по убыванию знак меняется на <, а набор колонок в условии должен совпадать с набором в order by.
Ещё две детали. В PostgreSQL order by signup_date desc ставит NULL первыми — пустое значение считается «большим», — поэтому для незаполненных дат пишут явное nulls last. А если задача звучит как «первая десятка», а на десятом месте ничья, limit 10 обрежет список произвольно: fetch first 10 rows with ties вернёт всех, кто делит последнее место, и строк станет больше десяти, зато результат перестанет зависеть от того, какую из равных строк база увидела раньше.
| Задача | Приём | На что смотреть |
|---|---|---|
| посмотреть пример строк | order by + limit | порядок задан явно, число не уходит в отчёт |
| первые страницы списка | limit + offset | стоимость растёт с номером страницы |
| глубокое листание | keyset по кортежу | колонки условия совпадают с order by |
| топ-N с честными ничьими | fetch first N rows with ties | строк может быть больше N |
| топ-N внутри каждой группы | ROW_NUMBER или RANK | ранжируется агрегат, а не сырая строка |
select user_id, signup_date, channel
from users
where signup_date >= date '2026-06-01'
and signup_date < date '2026-06-04'
order by signup_date, user_id
limit 4;-- продолжаем с последней пары, а не пропускаем четыре строки
select user_id, signup_date, channel
from users
where signup_date >= date '2026-06-01'
and signup_date < date '2026-06-04'
and (signup_date, user_id) > (date '2026-06-01', 4)
order by signup_date, user_id
limit 4;Сквозной пример: три таблицы, на которых можно всё повторить
Дальше идёт разбор целиком — от вопроса коллеги до ответа, который можно защитить. Он построен на учебной базе песочницы: сервис для совместной работы, три таблицы из пяти, первые дни июня. Период маленький нарочно — 98 регистраций за три дня можно пересчитать по дням и поймать себя на ошибке до того, как её увидит кто-то ещё.
Первое, что стоит сделать с незнакомой базой, — назвать зерно каждой таблицы одной фразой. Это занимает минуту и снимает половину будущих вопросов: становится видно, где строка означает человека, где действие, а где деньги.
| Таблица | Одна строка — это | Ключевые колонки |
|---|---|---|
| users | один зарегистрированный пользователь | user_id, signup_date, channel, country |
| events | одно действие пользователя в продукте | event_id, user_id, event_time, event_name |
| payments | один платёж | payment_id, user_id, paid_at, amount, plan |
Шаг 1. Уточнить вопрос раньше, чем открыть редактор
Менеджер продукта пишет в чат: «Пришли список пользователей из organic за начало июня». Звучит однозначно, но в этой фразе спрятаны как минимум три решения, и каждое меняет результат.
Что такое «начало июня» — первые три дня, первая неделя или весь месяц? Что такое «пользователи из organic» — те, у кого канал записан как organic в момент регистрации, или те, кто пришёл без рекламной метки вообще? И что именно нужно в ответе: список людей для выгрузки или их количество для слайда? От последнего зависит, будет ли в запросе SELECT полей или COUNT.
Уточнить это дешевле до запроса. Ответ «47 пользователей» и ответ «90 пользователей» одинаково верны для разных прочтений одной просьбы, но обсуждать вы их будете как ошибку.
| Прочтение | Условие периода | Сколько строк вернёт |
|---|---|---|
| первые три дня июня | signup_date >= '2026-06-01' AND signup_date < '2026-06-04' | 47 пользователей |
| первая неделя июня | signup_date >= '2026-06-01' AND signup_date < '2026-06-08' | 90 пользователей |
| весь июнь | signup_date >= '2026-06-01' AND signup_date < '2026-07-01' | 478 пользователей |
Шаг 2. Собрать запрос и посмотреть на размер выборки
Договорились: нужен список organic-пользователей за первые три дня июня, с датой регистрации и страной. Запрос собирается ровно из тех частей, которые разобраны выше, и читается как формулировка задачи.
Обратите внимание на две детали. Верхняя граница периода записана как «меньше 4 июня», а не «меньше или равно 3 июня» — так запрос не сломается, если однажды в колонке окажется время, а не только дата. И сортировка задана явно: без ORDER BY база не обязана возвращать строки в одном порядке, а список, который меняется между запусками, невозможно обсуждать.
SELECT
user_id,
signup_date,
country
FROM users
WHERE channel = 'organic'
AND signup_date >= DATE '2026-06-01'
AND signup_date < DATE '2026-06-04'
ORDER BY signup_date, user_id;Шаг 3. Сверить результат руками
Запрос вернул 47 строк. На учебной базе это можно проверить пересчётом по дням: из organic 1 июня зарегистрировались 15 человек, 2 и 3 июня — по 16, значит в трёхдневное окно попадают ровно 47. Совпало.
Такая сверка кажется избыточной ровно до первого случая, когда она не сходится. На реальных данных пересчитать руками нельзя, но приём остаётся: посчитайте ожидаемый порядок величины заранее и сравните. «Ожидаю примерно сотню, получил четыре» — это сигнал искать опечатку в значении канала или неверную границу периода, а не радоваться быстрому ответу.
Полезно так же проверить дополнение выборки: сколько пользователей за тот же период пришло не из organic. Если сумма двух чисел не равна общему количеству регистраций за период, где-то потерялись строки — обычно из-за NULL в колонке канала. Запрос ниже считает все четыре числа сразу, и его удобно держать под рукой как шаблон проверки для любого фильтра по категории.
| user_id | signup_date | country |
|---|---|---|
| 4 | 2026-06-01 | RU |
| 5 | 2026-06-01 | AM |
| 6 | 2026-06-01 | RU |
SELECT
count(*) AS all_signups,
count(*) FILTER (WHERE channel = 'organic') AS organic_signups,
count(*) FILTER (WHERE channel <> 'organic') AS other_signups,
count(*) FILTER (WHERE channel IS NULL) AS unknown_channel
FROM users
WHERE signup_date >= DATE '2026-06-01'
AND signup_date < DATE '2026-06-04';
-- 98 | 47 | 51 | 0: 47 + 51 = 98, строк без канала нетШаг 4. Отдать ответ так, чтобы его можно было перепроверить
Голое число или таблица без контекста порождают второй круг вопросов. К ответу стоит приложить три вещи: как определён период, что считается пользователем нужного канала и сколько всего строк было до фильтра. Это две строки текста, которые экономят получасовое обсуждение.
Формулировка может выглядеть так: «За 1–3 июня из organic зарегистрировались 47 пользователей из 98 регистраций за этот период. Канал берётся из users.channel на момент регистрации; период — по дате регистрации, верхняя граница не включена». Теперь любой коллега видит, какое именно решение вы приняли за него, и может возразить по существу.
Тот же принцип работает и дальше по курсу: чем сложнее запрос, тем важнее рядом с числом называть зерно, период и правило отбора. Метрику без этих трёх вещей нельзя ни проверить, ни повторить.
Минимальный чеклист перед первым SQL-ответом
Перед отправкой результата проверьте не только зелёный статус выполнения. Назовите зерно строки, период и таймзону, условия фильтра, отношение NULL и ожидаемый размер базы. Найдите одну строку, которая должна попасть в ответ, и одну, которая должна быть исключена.
Если запрос отвечает на вопрос про пользователей, не называйте его метрикой, пока не проверили уникальность user_id. Следующий шаг — агрегирование через GROUP BY и COUNT(DISTINCT), но базовая аудитория должна быть понятна заранее.
Что изучать дальше
После SELECT, FROM и WHERE логично перейти к GROUP BY, COUNT и COUNT(DISTINCT). Именно там список строк превращается в метрику: DAU по дням, пользователи по каналам или конверсия между шагами воронки. Но сначала закрепите базовую механику на нескольких собственных вопросах.
Материалы по теме
SQL-запросы: 40 примеров для аналитика с результатами
Сорок SQL-запросов на одной учебной базе: SELECT и WHERE, COUNT и GROUP BY, JOIN, даты, оконные функции, DAU, retention и A/B-тест. У каждого запроса показан результат.

SQL для аналитика: как считать продуктовые метрики запросами
С чего начать в SQL, как читать grain, фильтровать события, считать уникальных пользователей, соединять таблицы и превращать результат в вывод по продукту.

WHERE в SQL: условия, AND и OR, NULL и фильтр по дате
WHERE в SQL с примерами: несколько условий, приоритет AND и OR, IN, BETWEEN, LIKE, NULL, фильтр по дате, отличие от HAVING и способ проверить результат.