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

Ошибки в SQL-запросах: тексты сообщений, причины и исправления

Частые ошибки SQL с дословными текстами PostgreSQL и DuckDB: GROUP BY, column does not exist, ambiguous, division by zero, типы и даты, и пять запросов, которые молча врут.

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

Ошибку в SQL обычно ищут, скопировав сообщение в поиск: column "users.country" must appear in the GROUP BY clause, relation "payment" does not exist, division by zero. Эта страница устроена под такой поиск: заголовок — дословный текст сообщения, под ним причина и исправление. Тексты получены на PostgreSQL 16 и на DuckDB — той базе, на которой работает песочница SQL-курса. Во второй половине — ошибки опаснее: запрос выполняется, а число неверное. На учебной базе курса такой запрос показывает, что платят 100% пользователей каждого канала, хотя на самом деле платят от 8,5% до 26,8%.

Коротко

Сообщение об ошибке почти всегда называет и место, и объект. Прочитайте его целиком, включая строку LINE, стрелку под ней и HINT: в половине случаев там уже написано исправление.

  • Колонка без агрегата должна стоять в GROUP BY. Условие на агрегат — в HAVING, а не в WHERE.
  • Алиас из SELECT PostgreSQL не видит в WHERE и HAVING, DuckDB видит. Запрос из песочницы может упасть на рабочей базе.
  • Строки в SQL пишут в одинарных кавычках. Двойные означают имя колонки: "organic" — это колонка organic, которой нет.
  • PostgreSQL останавливает запрос на делении на ноль, DuckDB возвращает Infinity. NULLIF(знаменатель, 0) даёт NULL в обеих.
  • Самые дорогие ошибки выполняются без сообщения: условие на правую таблицу LEFT JOIN в WHERE, JOIN двух таблиц фактов, OR без скобок, <> рядом с NULL и неполный последний день.

Сообщение, причина и исправление: сводная таблица

Тексты PostgreSQL 16. Соответствующие сообщения DuckDB приведены в разделах ниже.

Частые ошибки SQL-запросов
Сообщение PostgreSQLПричинаИсправление
syntax error at or near "FROM"лишняя запятая перед FROM или слово без ASубрать запятую, алиас писать через AS
column "users.country" must appear in the GROUP BY clause or be used in an aggregate functionколонка без агрегата не в GROUP BYдобавить в GROUP BY или обернуть в агрегат
aggregate functions are not allowed in WHEREcount, sum и другие в WHEREперенести условие в HAVING
window functions are not allowed in WHERErow_number и другие окна в WHEREпосчитать окно в CTE, фильтровать снаружи
column "amout" does not existопечатка, алиас в WHERE, двойные кавычкипроверить имя, повторить выражение
relation "payment" does not existнет такой таблицы в search_pathпроверить имя и схему
missing FROM-clause entry for table "p"алиас таблицы не объявлен в FROMдобавить JOIN или исправить алиас
column reference "user_id" is ambiguousколонка есть в двух таблицахуказать алиас таблицы
division by zeroзнаменатель равен нулюNULLIF(знаменатель, 0)
operator does not exist: date = integerсравнение разных типовлитерал даты в кавычках или CAST
invalid input syntax for type integer: "abc"строка не приводится к типу колонкисравнивать с значением нужного типа
more than one row returned by a subquery used as an expressionскалярный подзапрос вернул несколько строкагрегат, LIMIT 1 или JOIN
each UNION query must have the same number of columnsразное число колонок в частях UNIONвыровнять списки колонок

Как читать сообщение об ошибке SQL

PostgreSQL отвечает тремя-четырьмя строками. ERROR: — сам текст. LINE 1: — фрагмент запроса, и стрелка ^ под ним показывает символ, на котором разбор остановился. HINT: — подсказка, если база знает, что предложить. Стрелка указывает, где база сломалась, а не где ошиблись вы: при пропущенной запятой она стоит на следующем слове.

DuckDB начинает сообщение с класса ошибки, и он сразу говорит, на каком шаге всё сломалось. Parser Error — запрос не разобран, это синтаксис. Catalog Error — нет таблицы или функции. Binder Error — база не смогла связать имена: колонку, алиас, группировку. Conversion Error — значение не приводится к типу. Invalid Input Error — проблема в данных при выполнении.

Песочница SQL-курса работает на DuckDB и показывает те же ошибки по-русски, с подсказкой о вероятной причине. В DBeaver, psql или ноутбуке вы увидите оригинальный английский текст — именно его и имеет смысл копировать в поиск.

Ответ PostgreSQL 16 на опечатку в имени колонки
SELECT sum(amout) FROM payments;

-- ERROR:  column "amout" does not exist
-- LINE 1: SELECT sum(amout) FROM payments;
--                    ^
-- HINT:  Perhaps you meant to reference the column "payments.amount".

syntax error at or near: запятые, кавычки и алиасы без AS

Лишняя запятая перед FROM — самая частая синтаксическая ошибка: SELECT channel, count(*) AS users, FROM users. PostgreSQL отвечает syntax error at or near "FROM". DuckDB такую запятую прощает и выполняет запрос. Поэтому запрос, отлаженный в песочнице, может упасть при переносе в PostgreSQL.

Пропущенная запятая даёт ошибку в неожиданном месте. В SELECT channel count(*) … обе базы пишут syntax error at or near "(": они прочитали count как алиас колонки channel и споткнулись о скобку.

Алиасы без AS — вторая ловушка. SELECT channel order FROM users падает в обеих базах, потому что ORDER — зарезервированное слово. DuckDB строже: count(*) share и channel ref там дают Parser Error: syntax error at or near "share", хотя PostgreSQL такие алиасы принимает. С AS все эти имена, включая AS order и AS user, работают в обеих базах. Правило простое: пишите AS всегда.

Одинарные и двойные кавычки не взаимозаменяемы. WHERE channel = "organic" ищет колонку с именем organic. PostgreSQL: column "organic" does not exist. DuckDB: Referenced column "organic" not found in FROM clause! со списком похожих колонок. Строковые значения — только в одинарных кавычках: 'organic'.

column must appear in the GROUP BY clause or be used in an aggregate function

Запрос SELECT channel, country, count(*) FROM users GROUP BY channel просит одну строку на канал, но в каждом канале несколько стран, и база не знает, какую показать. PostgreSQL отвечает column "users.country" must appear in the GROUP BY clause or be used in an aggregate function. DuckDB — column "country" must appear in the GROUP BY clause or must be part of an aggregate function. и предлагает ANY_VALUE(country), если конкретное значение неважно.

Исправления два, и выбор зависит от вопроса. Если нужна разбивка по каналу и стране, добавьте country в GROUP BY: строк станет больше. Если нужна одна строка на канал, оберните country в агрегат: count(DISTINCT country) или string_agg. ANY_VALUE подходит только для колонок, которые внутри группы одинаковы по смыслу, иначе результат случаен.

Та же ошибка появляется и без GROUP BY. SELECT channel FROM users HAVING count(*) > 10 превращает всю таблицу в одну группу, и channel снова оказывается без агрегата.

Исправленный запрос: канал и страна в группировке
SELECT channel, country, count(*) AS users
FROM users
GROUP BY channel, country
ORDER BY users DESC
LIMIT 3;

-- organic RU 1015, referral RU 614, paid_search RU 601

aggregate functions are not allowed in WHERE

WHERE фильтрует строки до группировки, поэтому агрегата в нём ещё нет. WHERE count(*) > 1000 в PostgreSQL даёт aggregate functions are not allowed in WHERE, в DuckDB — WHERE clause cannot contain aggregates!. Условие на результат группировки пишут в HAVING: HAVING count(*) > 1000 оставляет organic, referral и paid_search.

С оконными функциями то же самое, только HAVING не поможет. WHERE row_number() OVER (…) = 1 падает с window functions are not allowed in WHERE в PostgreSQL и WHERE clause cannot contain window functions! в DuckDB. Окно считают в CTE, фильтруют снаружи. В DuckDB есть сокращение QUALIFY, но PostgreSQL его не знает и отвечает syntax error at or near "row_number".

Вложить агрегат в агрегат тоже нельзя: avg(count(*)) в обеих базах даёт aggregate function calls cannot be nested. Среднее по группам считают в два шага: сначала count по группе в подзапросе или CTE, потом avg снаружи.

Первый платёж каждого пользователя: окно в CTE, фильтр снаружи
WITH ranked AS (
  SELECT
    user_id,
    paid_at,
    row_number() OVER (PARTITION BY user_id ORDER BY paid_at, payment_id) AS rn
  FROM payments
)
SELECT count(*) AS first_payments
FROM ranked
WHERE rn = 1;

-- 912

column does not exist: опечатка, алиас в WHERE и HAVING

Опечатку в имени PostgreSQL ловит с подсказкой: column "amout" does not exist и HINT: Perhaps you meant to reference the column "payments.amount". DuckDB: Referenced column "amout" not found in FROM clause! и список кандидатов, первым из которых стоит amount.

Сложнее, когда имя верное, но база его ещё не знает. SELECT amount * 0.8 AS net FROM payments WHERE net > 30 падает в PostgreSQL с column "net" does not exist: WHERE выполняется раньше SELECT, и алиаса net на этом шаге нет. DuckDB этот запрос выполняет. То же с HAVING: HAVING user_count > 1000 в PostgreSQL даёт column "user_count" does not exist, в DuckDB работает.

Есть и вовсе странный вариант. Если алиас совпал с именем таблицы, как count(*) AS users в запросе к таблице users, то HAVING users > 1000 в PostgreSQL падает с operator does not exist: users > integer: имя таблицы там обозначает целую строку. Переносимое исправление одно — повторить выражение: WHERE amount * 0.8 > 30, HAVING count(*) > 1000. Если выражение длинное, вынесите его в CTE.

relation does not exist и missing FROM-clause entry

relation "payment" does not exist означает, что PostgreSQL не нашёл таблицу с таким именем в схемах из search_path. Причин три: опечатка (здесь — единственное число вместо payments), таблица в другой схеме и нужно писать analytics.payments, или имя создано в кавычках с заглавными буквами и теперь требует тех же кавычек. DuckDB пишет Table with name payment does not exist! и сразу предлагает Did you mean "payments"?

missing FROM-clause entry for table "p" — вы обращаетесь к алиасу, которого нет в FROM. Обычно JOIN удалили или закомментировали, а колонку p.amount в SELECT оставили. DuckDB: Referenced table "p" not found! со списком доступных алиасов.

column reference is ambiguous

После JOIN колонка user_id есть и в users, и в payments. SELECT user_id … GROUP BY user_id не говорит, какую взять. PostgreSQL: column reference "user_id" is ambiguous. DuckDB подсказывает оба варианта: Ambiguous reference to column name "user_id" (use: "u.user_id" or "p.user_id").

Исправление — указать алиас таблицы: u.user_id. Для LEFT JOIN это не формальность: у пользователей без платежей p.user_id будет NULL, а u.user_id — нет. Если колонки соединения называются одинаково, JOIN payments p USING (user_id) склеивает их в одну, и неоднозначности не возникает.

division by zero: деление на ноль в SQL

Конверсия, средний чек и доля — всё это деление, и знаменатель может оказаться нулём: в сегменте нет пользователей, в дне нет заказов. PostgreSQL останавливает весь запрос с division by zero, даже если ноль был в одной группе из ста.

DuckDB не падает. Деление через / там возвращает Infinity, целочисленное // — NULL. Infinity опаснее ошибки: он проходит в отчёт, ломает средние и сортировку, а в BI может выглядеть как очень большое число.

Переносимое решение — NULLIF(знаменатель, 0): при нуле он превращается в NULL, и результат деления тоже NULL в обеих базах. Отдельно следите за целочисленным делением: в PostgreSQL 912 / 4613 равно 0, а не 0,198. Подробно об этом — в статье про типы данных и CAST.

Знаменатель, который бывает нулём
SELECT
  channel,
  count(*) / NULLIF(sum(CASE WHEN country = 'XX' THEN 1 ELSE 0 END), 0) AS ratio
FROM users
GROUP BY channel
ORDER BY channel;

-- без NULLIF: PostgreSQL — ERROR: division by zero, DuckDB — Infinity
-- с NULLIF: NULL во всех четырёх строках

operator does not exist и invalid input syntax: ошибки типов

Дату сравнили с числом: WHERE signup_date = 20260715. PostgreSQL: operator does not exist: date = integer и совет добавить явное приведение. DuckDB: Conversion Error: Unimplemented type for cast (INTEGER -> DATE). Дату пишут строкой в формате ISO: '2026-07-15' или DATE '2026-07-15'.

Дату написали в русском формате: WHERE signup_date >= '15.07.2026'. PostgreSQL: date/time field value out of range: "15.07.2026" и HINT: Perhaps you need a different "datestyle" setting. DuckDB: invalid date field format: "15.07.2026", expected format is (YYYY-MM-DD). Хуже, когда число дня не больше двенадцати: '03.08.2026' PostgreSQL при настройке MDY молча прочитает как 8 марта.

Число сравнили со строкой: WHERE user_id = 'abc'. PostgreSQL: invalid input syntax for type integer: "abc", DuckDB: Could not convert string 'abc' to INT32. Такое сообщение часто приходит из BI-фильтра, который подставил текст в числовое поле.

Функции другой базы дают свою ошибку: datediff в PostgreSQL — function datediff(unknown, date, date) does not exist. Какие функции даты есть в какой СУБД, собрано в справочнике функций даты.

more than one row returned by a subquery, UNION и ORDER BY

Скалярный подзапрос в SELECT обязан вернуть одну строку. (SELECT p.amount FROM payments p WHERE p.user_id = u.user_id) возвращает несколько строк у тех, кто продлевал подписку. PostgreSQL: more than one row returned by a subquery used as an expression. DuckDB: More than one row returned by a subquery used as an expression - scalar subqueries can only return a single row. Исправление — агрегат в подзапросе (sum, max) или JOIN с группировкой.

Части UNION должны возвращать одинаковое число колонок. PostgreSQL: each UNION query must have the same number of columns, DuckDB: Set operations can only apply to expressions with the same number of result columns.

ORDER BY по номеру колонки, которой нет: ORDER BY 3 при двух колонках. PostgreSQL: ORDER BY position 3 is not in select list, DuckDB: ORDER term out of range - should be between 1 and 2. Обычно колонку удалили из SELECT, а номер в сортировке остался. Поэтому сортируйте по имени, а не по номеру.

Запрос выполнился, но число неверное: LEFT JOIN и условие в WHERE

Ошибки с сообщением безопасны: запрос не выполнился, и неверное число никуда не ушло. Дальше — пять ошибок, которые выполняются без сообщения. Все они найдены на учебной базе, и во всех PostgreSQL и DuckDB дают одинаковый неверный ответ.

Задача: доля плательщиков по каналам. Пользователей соединяют с платежами через LEFT JOIN, чтобы не потерять неплативших, а условие «только первый платёж» пишут в WHERE. У неплативших все колонки payments равны NULL, и WHERE p.payment_type = 'first' отбрасывает их. LEFT JOIN превращается в INNER: в выборке 912 пользователей, все они плательщики, доля в каждом канале — 100%.

Условие на правую таблицу LEFT JOIN пишут в ON: LEFT JOIN payments p ON p.user_id = u.user_id AND p.payment_type = 'first'. Тогда в выборке 4 613 пользователей, и доля получается честной. Подробнее о потерях и дублях в соединениях — в статье про JOIN.

Доля плательщиков по каналам: условие в WHERE и в ON
КаналWHERE: пользователейWHERE: доляON: пользователейON: доля
organic410100%1 74723,5%
referral278100%1 03726,8%
partner138100%81417,0%
paid_search86100%1 0158,5%

Две таблицы фактов в одном JOIN раздувают сумму

Задача: выручка и число открытий приложения по каналам в одной таблице. Естественный запрос соединяет users с payments и с events. Но у пользователя несколько платежей и много событий, и каждая пара «платёж × событие» становится строкой. Выручка умножается на число открытий, открытия — на число платежей.

Итог по всей базе: вместо 30 639 выручки запрос показывает 235 833, в 7,7 раза больше. Ошибка неравномерная: у organic выручка завышена в 8 раз, у paid_search — в 4,2. Сравнение каналов искажено, а числа выглядят правдоподобно.

Лечится сменой порядка: сначала каждую таблицу фактов свернуть до зерна «канал», потом соединить свёрнутые результаты. Две CTE и один JOIN по каналу — и выручка сходится с суммой по payments.

Выручка по каналам: JOIN трёх таблиц против свёртки по отдельности

Всего 235 833 против 30 639. Учебная база SQL-курса, одинаково в PostgreSQL 16 и DuckDB.

users × payments × eventsСвёрнуто до канала
Выручка и открытия по каналам без размножения строк
WITH revenue AS (
  SELECT u.channel, sum(p.amount) AS revenue
  FROM users u
  JOIN payments p ON p.user_id = u.user_id
  GROUP BY u.channel
),
opens AS (
  SELECT u.channel, count(*) AS app_opens
  FROM users u
  JOIN events e ON e.user_id = u.user_id
  WHERE e.event_name = 'app_open'
  GROUP BY u.channel
)
SELECT r.channel, r.revenue, o.app_opens
FROM revenue r
JOIN opens o ON o.channel = r.channel
ORDER BY r.channel;

-- organic 14114 / 12265, paid_search 2467 / 3870,
-- partner 4732 / 4576, referral 9326 / 7982

AND без скобок и <>, который теряет NULL

AND выполняется раньше OR. Условие «платные каналы на мобильных» часто пишут так: WHERE channel = 'paid_search' OR channel = 'partner' AND device = 'mobile'. База читает его как «весь paid_search плюс мобильный partner» и возвращает 1 343 пользователя. Правильный ответ — 998: WHERE channel IN ('paid_search', 'partner') AND device = 'mobile'. Когда в условии есть и AND, и OR, ставьте скобки или заменяйте OR на IN.

Сравнение с NULL не бывает истинным. WHERE country <> 'RU' возвращает 1 843 пользователя, хотя не из России 1 898: 55 человек без страны пропали, потому что NULL <> 'RU' — это NULL, а не true. Если пропуски должны попасть в выборку, пишите country IS DISTINCT FROM 'RU' или добавляйте OR country IS NULL.

Неполный последний день выглядит как падение

Выгрузка учебной базы закрыта 30 августа в 12:59. За этот день 226 событий против 588 накануне и 570–690 в остальные дни недели. На графике по дням последняя точка обрывается вниз, и первый вопрос в чате — «что сломалось 30 августа». Не сломалось ничего: день ещё не закончился.

Правило: перед построением ряда проверьте max(event_time) и отрежьте незакрытый период или подпишите его как неполный. То же с последней неделей и последним месяцем. Инциденты в событиях — повторную доставку, поздние данные и неполные дни — разбирает пятнадцатая глава курса.

События по дням, 24–30 августа 2026

30 августа — неполный день: выгрузка закрыта в 12:59. Учебная база SQL-курса.

События

Чеклист перед отправкой результата

Пять минут проверки дешевле, чем исправление в чужой презентации.

  • Сумма по группам сходится с общим итогом, посчитанным отдельным простым запросом.
  • Число строк результата соответствует зерну: одна строка на пользователя, канал или день.
  • После JOIN count(*) и count(DISTINCT ключ) совпадают, если строка должна быть одна на ключ.
  • Условия на правую таблицу LEFT JOIN стоят в ON, а не в WHERE.
  • В условиях с AND и OR расставлены скобки, сравнения с <> учитывают NULL.
  • Каждое деление защищено NULLIF(…, 0), хотя бы один операнд не целый.
  • Последний период полный или явно подписан как неполный.
  • Запрос проверен в той базе, где он будет работать, а не только в песочнице.

Частые вопросы

Почему запрос работает в DuckDB и падает в PostgreSQL? DuckDB мягче: принимает висячую запятую, алиасы в WHERE и HAVING, QUALIFY, не падает на делении на ноль. Перед переносом проверьте эти места.

Что значит «ошибка выполнения SQL-запроса» в BI? Это обёртка BI-инструмента. Оригинальное сообщение базы обычно лежит в подробностях ошибки или в логе запроса, и искать нужно по нему.

Почему HAVING не видит алиас? В PostgreSQL HAVING выполняется до SELECT, и алиасов там ещё нет. Повторите агрегат: HAVING count(*) > 1000.

Как найти, в какой части длинного запроса ошибка? Запускайте CTE по одной: SELECT * FROM первая_cte LIMIT 10, затем следующую. Ошибка окажется в первой, которая не выполнится или вернёт неожиданное число строк.

Итог

Сообщение об ошибке — подсказка, а не приговор: оно называет объект, место и часто исправление. Опаснее запросы, которые выполнились. Их ловят не чтением кода, а сверкой: итог с суммой по группам, count(*) с count(DISTINCT), последний день с предыдущими.

Аудиту чужого SQL посвящена четырнадцатая глава курса: там на заданиях с проверкой ищут раздутую выручку, сломанный LEFT JOIN и дубли событий. Первые главы курса открыты бесплатно.

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