Хочу рассказать про баг, который я искал полтора дня и который оказался опечаткой длиной в одну инструкцию. Если коротко: PostgreSQL (16.4 в моём случае, но воспроизводится начиная с 12-й) не разрешает UNION ALL сразу после ORDER BY ... LIMIT N без круглых скобок. И самое неприятное - ошибку об этом я узнал последним, потому что между моим SQL-запросом и логами стояло пять промежуточных слоёв, каждый из которых её по-своему проглотил.
Расскажу как нашёл, как фиксил, и что в итоге добавил в pre-deploy чек-лист.

Симптом: тихий пустой ответ от webhook
У меня есть n8n-воркфлоу, который раз в день проверяю - там GET /webhook/result отдаёт финальную карточку клиента в JSON: профиль, статус, прикреплённые объекты, лид-инфа. Простой read-only эндпоинт. Цепочка из шести нод: webhook trigger -> Postgres Load Session -> Postgres Load Lead -> Postgres Load Properties -> Code Build Card -> Respond to Webhook. Ничего хитрого.
В пятницу вечером фронтенд начал стабильно падать на странице результата с пустым ответом. Открыл DevTools - 200 OK, content-type application/json, body пустой. То есть никакой ошибки на сервере, просто 0 байт.
Самое раздражающее, что у меня в голове сразу выстроился неправильный причинно-следственный ряд: «фронт раньше работал, я последний раз правил рендер карточки, наверное где-то null-pointer». Полез смотреть фронт. Конечно ничего не нашёл (потому что нечего смотреть, body пустой). Откатил последний релиз - продолжает падать. Так что нет, дело не во фронте.
Перешёл смотреть n8n. Открываю executions - workflow выполняется, отдаёт ok: true, никаких error-выходов. То есть ноды формально пройдены. Но что-то по дороге становится пустым.
В понедельник утром, после кофе и с ясной головой, наконец сел открывать каждую ноду по очереди и смотреть Output руками. На второй ноде - Postgres: Load Lead - увидел что она отдаёт пустой массив []. Ну ок, лида для этой сессии может и не быть. Дальше код-нода Build Card ждёт минимум 1 строку (берёт [0]) и при [0] === undefined молча возвращает пустой объект. Который и улетает в Respond. Вот и весь пустой ответ.
Дальше начинается интересное.
Что было в SQL
Запрос в Load Lead был написан так (я писал его несколько недель назад, копируя свой же паттерн откуда-то с прошлого проекта):
SELECT summary, escalation_reason, created_at FROM leads WHERE session_id = '...' ORDER BY created_at DESC LIMIT 1 UNION ALL (SELECT NULL, NULL, NULL) LIMIT 1
Идея логичная: «возьми последний лид для сессии, а если его нет - верни строку с NULL-полями, чтобы downstream-нода не споткнулась о пустой массив». Без UNION ALL n8n получал бы 0 items и обрывал цепочку.
Запрос вроде читается. Лид есть - вернёт его. Лида нет - первая часть пустая, UNION ALL приклеит NULL-fallback, итого LIMIT 1 ограничит до одной строки. Всё.
Берёшь этот SQL, скармливаешь psql:
psql ... -c " SELECT 1 AS x WHERE 1=2 ORDER BY x DESC LIMIT 1 UNION ALL (SELECT NULL) LIMIT 1 "
И получаешь:
ERROR: syntax error at or near "UNION" LINE 2: SELECT 1 AS x WHERE 1=2 ORDER BY x DESC LIMIT 1 UNION ALL (S...
Сюрприз. PostgreSQL такое не понимает. Хотя по идее всё разумно.
Почему PostgreSQL так строг
Если копнуть в SQL:2011 спеку (или в документацию PostgreSQL про UNION) - грамматика <query expression> устроена так, что ORDER BY и LIMIT относятся к внешнему запросу, к результату всех UNION/INTERSECT/EXCEPT. То есть когда вы пишете:
SELECT a FROM t1 ORDER BY a LIMIT 1 UNION ALL SELECT b FROM t2
Парсер не может решить, к чему относится ORDER BY a LIMIT 1 - к первому SELECT (что вы хотели) или вообще странная конструкция, потому что после LIMIT должен идти конец query, а не UNION.
Чтобы сказать парсеру «ORDER BY/LIMIT относится только к первому SELECT» - его надо обернуть в скобки. Тогда это становится <query primary>, который имеет право иметь свои собственные сортировки и лимиты.
Это не баг постгреса. Это так и задумано стандартом, чтобы не было неоднозначности. Но узнаёшь об этом только когда упрёшься.
Честно - я и до этого случая знал это правило теоретически, но никогда не задумывался об edge-кейсах с LIMIT ровно на той же строке что и UNION. В обычной практике ORDER BY всё-таки чаще ставят в самом конце, в <query expression>, и проблемы не возникает.
Три варианта фикса
После того как я понял в чём дело, у меня было три рабочих способа починить.
Вариант 1: круглые скобки
Самое прямолинейное решение - то самое, на которое прямо намекает спека:
(SELECT summary, escalation_reason, created_at FROM leads WHERE session_id = '...' ORDER BY created_at DESC LIMIT 1) UNION ALL SELECT NULL::text, NULL::text, NULL::timestamptz LIMIT 1
Скобки превращают первую часть в <query primary> со своими ORDER BY/LIMIT. Парсер счастлив, запрос работает.
Подвох: если первая часть нашла запись, то после UNION ALL получится 2 строки (найденная + NULL-fallback), а внешний LIMIT 1 обрежет до первой. Но порядок строк в UNION ALL без явной сортировки не гарантирован. То есть теоретически вы можете получить NULL-строку даже когда лид существует. На практике PostgreSQL обычно отдаёт в порядке выполнения подзапросов и этого не происходит, но полагаться я бы не стал.
Чтобы гарантированно получать «реальную» строку первой - надо добавить ещё один уровень с ORDER BY по флагу:
SELECT summary, escalation_reason, created_at FROM ( (SELECT 0 AS prio, summary, escalation_reason, created_at FROM leads WHERE session_id = '...' ORDER BY created_at DESC LIMIT 1) UNION ALL SELECT 1 AS prio, NULL::text, NULL::text, NULL::timestamptz ) t ORDER BY prio LIMIT 1
Уже не так красиво. Поэтому я этот вариант отбросил.
Вариант 2: RIGHT JOIN с константной таблицей
Это то, что я в итоге выкатил. Идея простая: сделать join с гарантированно непустой константной строкой, чтобы downstream всегда получал ровно одну строку - либо с данными, либо с NULL-полями.
SELECT summary, escalation_reason, created_at FROM ( SELECT summary, escalation_reason, created_at FROM leads WHERE session_id = '...' ORDER BY created_at DESC LIMIT 1 ) t RIGHT JOIN (SELECT 1 AS d) s ON true
Что тут происходит:
Внутренний
SELECT ... LIMIT 1живёт в скобках без проблем (это derived table)(SELECT 1 AS d)- константа, гарантированно одна строкаRIGHT JOIN ... ON trueозначает: «возьми все строки правой части (всегда одна) и соедини с левой если есть пара». ПриON trueпара есть всегда. Если левая пустая - правая возвращает себя с NULL во всех полях левойВнешний SELECT берёт только колонки из t (поле d из правой нам не нужно)
Итог: ВСЕГДА одна строка. Если в leads есть запись - получаем её значения. Если нет - все NULL.
Прелесть в том, что я не описываю NULL-fallback явно (нет SELECT NULL, NULL, NULL) - он автоматически возникает из левого join'а. Если завтра я добавлю в SELECT ещё одну колонку, мне не нужно править fallback в двух местах. Только в одном внутреннем запросе. А типы - PostgreSQL вычислит сам.
Главный минус решения - оно непривычное. Когда коллега видит такой запрос впервые, ему нужно секунд тридцать чтобы понять что происходит. Я добавил коммент над запросом «// Trick: RIGHT JOIN with constant guarantees exactly 1 row, NULL when empty» - этого хватает.
Performance? На таблице leads с 50k записей и индексом по session_id запрос отрабатывает за 1.2 ms. RIGHT JOIN с однострочной константой - бесплатный по сути. Ничего не оптимизируется хуже чем оригинал.
Вариант 3: scalar subqueries
Кратко - можно вообще обойтись без UNION и JOIN, если просто разнести каждую колонку в отдельный scalar subquery:
SELECT (SELECT summary FROM leads WHERE session_id='...' ORDER BY created_at DESC LIMIT 1) AS summary, (SELECT escalation_reason FROM leads WHERE session_id='...' ORDER BY created_at DESC LIMIT 1) AS escalation_reason, (SELECT created_at FROM leads WHERE session_id='...' ORDER BY created_at DESC LIMIT 1) AS created_at
Каждый subquery возвращает скаляр или NULL. Итого получаем строку из трёх колонок, каждая либо со значением, либо с NULL.
Минус очевидный - один и тот же WHERE/ORDER BY/LIMIT повторяется три раза. Не страшно для маленькой таблицы (планировщик может закешировать), но на большой может стать заметно медленнее. Плюс если завтра нужно добавить колонку - править в каждом subquery.
В моём случае таблица leads не размером с Кострому, но я всё равно выбрал RIGHT JOIN-вариант - он чище читается и компактнее.
Почему n8n проглотил ошибку
Самое обидное в этой истории не сам SQL-баг, а то, что я не увидел error-сообщения вовремя. Postgres-нода в n8n, когда executeQuery падает с syntax error, ведёт себя по-разному в зависимости от настроек:
On Error: Stop Workflow- workflow обрывается, error-сообщение видно в executionsOn Error: Continue- workflow идёт дальше с пустым output, ошибки не видноOn Error: Continue (using error output)- workflow идёт дальше, но создаётся отдельный error-выход; ошибка доступна, если этот выход подключить к downstream-ноде
В моём workflow Load Lead был выставлен в Continue (using error output), но error-выход никуда не был подключен (просто потому что я не предусмотрел такой сценарий). Получилось: ошибка возникла, нода поставила 0 items на main-выход, error-выход остался обрывком. Workflow «успешно» завершился пустой картой.
Урок: если в Postgres-ноде SQL динамический (через expressions, через билдер, через template) - либо используйте Stop Workflow, чтобы любой syntax error падал громко, либо подключайте error-выход к Telegram-нотификации или к Sentry. Тихий error в продакшене - худшее что бывает.
Для моих воркфлоу я в итоге сделал гибрид: для read-only нод оставил Continue (потому что иногда легитимно ничего не находится), но добавил отдельную ноду-проверку перед Build Card: если предыдущий output пустой - сразу логирую event в Telegram. Это поймало бы мою же проблему ещё в пятницу.
Что добавил в pre-deploy чек-лист
Этот случай заставил меня формализовать одну вещь:
Любой SQL с
UNION/INTERSECT/EXCEPT, у которого части содержатORDER BY/LIMIT- обязательно прогоняется руками в psql ПЕРЕД сохранением в n8n. Не «потом проверим в браузере», не «всё равно же тестим воркфлоу через webhook» - именно через psql. Парсер n8n не равен парсеру psql, и иногда ошибка маскируется.
Заодно добавил себе универсальную проверку для любого нового SQL: запускаю с заведомо невыполнимым WHERE 1=2. Если получаю ожидаемый результат (пустая выдача либо корректный fallback) и структуру колонок такую же как при WHERE 1=1 - запрос можно деплоить. Это banalно но я как-то про это забывал, пока не получил полтора дня дебага.
Полезные ссылки
PostgreSQL docs - Combining Queries (UNION, INTERSECT, EXCEPT)
PostgreSQL docs - SELECT statement, ORDER BY/LIMIT semantics
Если есть свой любимый паттерн «всегда вернуть 1 строку, даже когда таблица пустая» - был бы рад увидеть в комментариях. У меня их получилось три, но наверняка есть и четвёртый.
Комментарии (13)

Zotann
07.05.2026 10:02В чем посыл статьи? Так сразу понятно что последний лимит применяется неизвестно куда - то ли ко всему запросу, то ли ко второму (так же и с условиями и прочим). Поставить скобочки для ясности и все.

viktdo Автор
07.05.2026 10:02Если коротко: посыл не про сам SQL, а про то, сколько слоёв проглатывают syntax error прежде чем он доходит до разработчика. Сам фикс действительно тривиальный, в статье я и пишу, что баг искал не в нём, а в системе вокруг.
Цепочка получилась такая: PostgreSQL-нода n8n с настройкой «Continue (using error output)» молча отдала пустой массив вместо исключения, downstream-нода с пустым массивом не упала а пробросила пустой объект, Respond to Webhook вернул HTTP 200 и пустое тело, фронт показал «нет данных», логи n8n executions показали статус
success. Пять уровней, и ни на одном ошибка не материализовалась. Про SQL-pitfall я в итоге узнал последним, когда руками скопировал запрос в psql.Полезный для меня вывод оттуда не «не забывай про скобки», а «не используй Continue using error output без подключённого error-выхода». В проектах где много динамического SQL эта настройка превращает любую опечатку в тихий 200 OK.
Кому скобки очевидны сразу, статья в первую очередь не для них. Для тех у кого в стеке n8n / Zapier / Make или любой low-code в продакшене, история про silent failures поверх SQL-ошибки полезнее самого SQL.
Akina
Да нет тут ничего разумного. И вы дальше даже сами объясняете, по какой именно причине.
При наличии ORDER BY / LIMIT в UNION правило архипростое. Есть скобки - применяется к подзапросу, нет скобок - ко всему запросу, если в тексте последнего подзапроса, иначе ошибка синтаксиса.
Читаем правило выше и составляем правильный запрос:
Если первый подзапрос вернул запись, то ORDER BY, применяемый к объединённому набору записей, сначала вернёт запись с имеющимся значением поля, и только после неё запись со значением NULL.
NULLS LASTв данном случае формально необязателен (но обязателен, если присутствует DESC - такова особенность сортировки у Postgres), но и беды от него никакой, а, поскольку сортируется не более 2 записей, то на производительность это не влияет.Обрамление ВСЕХ подзапросов скобками делает запрос ну совсем корректным. Хотя (опять же формально) обрамление скобками у последнего подзапроса можно и опустить. Но я советую делать наоборот, и каждый подзапрос обрамлять скобками, даже если у вас ни ORDER BY, ни LIMIT не присутствуют.
viktdo Автор
Поправка по делу, спасибо. Действительно, UNION ALL без внешнего ORDER BY порядок не гарантирует, и LIMIT 1 в моём варианте 1 теоретически может срезать найденную запись. Это баг, в продакшене стрельнул бы рано или поздно.
Правильный вариант, как вы и привели:
ORDER BY created_at NULLS LAST LIMIT 1поверх объединённого набора. Тогда найденная запись всегда выигрывает у NULL-fallback'а.У меня в работающем флоу в итоге стоит RIGHT JOIN (вариант 2 в статье), там проблема снимается конструктивно: если внутренний SELECT пустой, RIGHT JOIN сам даёт NULL'ы, если вернул строку, она и есть результат. Но для «правильного UNION ALL» ваш вариант каноничный.
Про обрамление скобками каждого подзапроса даже без ORDER BY/LIMIT согласен. Парсер прощает, но через полгода ты сам не помнишь, какое правило приоритета у UNION с LIMIT, и скобки сильно облегчают чтение. Особенно когда запрос дорастает до WITH или вложенных UNION.
Akina
Да не баг это, а совершенно нормальное, полностью законное и даже документированное поведение. Просто у вас оно выпало из памяти - не иначе, редко надобится. Или вы вообще с SQL "на вы", и не видите недетерминированности написанной вами конструкции. Я бы, наоборот, просто на автомате, даже не задумываясь, написал именно показанный мной запрос, для меня недетерминированность вашего запроса просто бросается в глаза.
viktdo Автор
Соглашусь, тут вы попали в точку. Я не профильный SQL-специалист, мой основной стек это n8n / интеграции / API-обвязка, а Postgres использую в роли хранилища данных для воркфлоу, иногда для обработки данных на стороне Posgres. То есть пишу SELECT’ы и JOIN’ы регулярно, а в углы спецификации UNION захожу раз в полгода. Эта недетерминированность для меня действительно не была очевидной из чтения запроса, я думал про неё на уровне «обычно работает» и не пошёл дальше.
Ваш разбор как раз ценен тем, что показывает другой уровень владения инструментом: у человека с настоящим SQL-бэкграундом MAX-aggregate-обёртка уже в моторике, а не выводится из соображений. У меня так с n8n или JavaScript внутри Code-нод, где косяки чужих воркфлоу видны с первого взгляда. С Postgres мне до этого далеко, и комменты вроде ваших как раз тот ускоренный апдейт моторики, ради которого и пишутся такие статьи.
viktdo Автор
Могу использовать ваш комент, если соберусь написать статью по этой теме. Если будет применимо? С указанием на вас, конечно же
Akina
Ой, да ради бога.. можно даже ничего не указывать - секрета-то здесь никакого нет.
ptr128
Если уже заниматься перфекционизмом, то так. Внимание на последний запрос.
Скрытый текст
Сравниваем
viktdo Автор
Спасибо за бенчмарк, MAX-aggregate подход я не пробовал и он действительно элегантнее моего RIGHT JOIN-варианта. План у него получается короче на одну ноду (Aggregate + Limit + Index Scan против Nested Loop Left Join + Result + Limit + Index Scan), и это гарантированно одна строка по семантике агрегата, без танцев с константной правой таблицей.
Один момент про safety этого паттерна: трюк работает только потому, что внутренний
LIMIT 1гарантирует не более одной строки на входе уMAX(). Если кто-то скопирует приём и уберёт LIMIT (или не заметит что подзапрос может вернуть >1 строки), тоMAX(summary)иMAX(created_at)перестанут соответствовать друг другу. Это будут максимумы независимых колонок, и фронт получит «франкенштейн-строку» из несвязанных полей разных лидов. Я бы в продакшене вокруг такого запроса оставил коммент прямо в SQL:-- Aggregate trick: requires inner LIMIT 1, otherwise MAX() per-column desyncs rows.С этой оговоркой соглашусь, что вариант чище RIGHT JOIN'а. Обновлённая иерархия для случая «вернуть ровно одну строку или fallback с NULL»: Aggregate-обёртка > UNION ALL с NULLS LAST > RIGHT JOIN с константной таблицей. На практике разница в 0.01 ms незначима, но план короче, значит читать и поддерживать проще, и Postgres в более сложных запросах будет оптимизировать предсказуемее.
ptr128
Ну так задача изначально звучала так. Была бы другая задача - был бы другой запрос.
viktdo Автор
Могу использовать ваш комент, если соберусь написать статью по этой теме. Если будет применимо? С указанием на вас, конечно же
viktdo Автор
Могу использовать ваш комент, если соберусь написать статью по этой теме. Если будет применимо? С указанием на вас, конечно же