ORM, query builder или raw SQL в Go: как выбрать
ORM vs query builder vs raw SQL в Go: сравните миграции, отладку и контроль над запросами, чтобы команде было проще выбрать без лишних сожалений.

Почему этот выбор быстро становится запутанным
Большинство приложений на Go начинают с маленькой и понятной структуры. Вам нужны несколько таблиц, несколько эндпоинтов и базовый цикл создания, чтения, обновления и удаления. На этом этапе почти любой подход к базе данных кажется нормальным.
Потом приложение начинает расти.
Редкий продукт долго остаётся на простом CRUD. Кто-то просит фильтры для админки, экспорт, дашборды, поиск, историю оплат или отчёт, который объединяет шесть таблиц и группирует данные тремя разными способами. В той же кодовой базе теперь есть простые чтения, сложные записи, фоновые задачи и несколько запросов, которые никто не хочет трогать в пятницу.
Вот почему тема «ORM vs query builder vs raw SQL in Go» так быстро становится запутанной. Вы выбираете не для того приложения, которое у вас есть сегодня. Вы выбираете для приложения через полгода после новых требований, изменений схемы и одной болезненной просадки в production.
Первая ловушка — это старт. Ранний этап может сделать инструмент лучше, чем он есть на самом деле. ORM часто кажется очень быстрым в первый день, потому что модели, связи и частые запросы собираются довольно легко. Raw SQL тоже может выглядеть аккуратно, потому что каждый запрос очевиден. Query builder находится посередине и кажется безопасным компромиссом.
Проблемы обычно начинаются позже, когда база меняется.
Новая колонка звучит как мелочь, пока вам не нужно обновить структуры, сканы, сгенерированный код, тесты и миграции. Переименованная таблица может затронуть десятки запросов. Soft delete, правила для нескольких клиентов и кастомные индексы делают компромиссы гораздо заметнее, чем это видно на первом удачном демо.
Медленные запросы показывают ту же проблему. Когда страница начинает грузиться две секунды вместо 200 миллисекунд, команде нужно найти точный SQL, посмотреть план выполнения и решить, что менять. Если запрос пришёл из слоёв ORM, отладка замедляется. Если всё написано на raw SQL, у вас есть контроль, но каждый день появляется больше ручной работы.
Миграции делают выбор ещё реальнее. Они заставляют команду ответить на практические вопросы: кто отвечает за изменения схемы, насколько тщательно вы их проверяете и сколько контроля над запросами вам нужно, когда схема перестаёт быть простой. Именно там и проявляется настоящая цена.
Что на самом деле меняет каждый вариант
ORM меняет то, как ваша команда думает о работе с базой. Люди начинают со структур Go, связей и вызовов методов, а инструмент превращает это в SQL. Сначала это кажется очень быстрым, особенно для простого CRUD, но вместе с этим часть реального запроса оказывается спрятана за библиотекой.
Query builder меняет меньше. Вы всё ещё пишете запрос в коде, но форма SQL остаётся видимой: выбранные столбцы, joins, фильтры, сортировка, лимиты. Вы жертвуете частью удобства ради понятности, и многим командам Go после роста проекта такой баланс даётся легче.
Raw SQL меняет работу сильнее всего. Вы пишете точный statement, проверяете точный statement и отвечаете за каждую ошибку. Это звучит тяжелее, но убирает догадки, когда запрос начинает тормозить или возвращает не те строки.
В повседневной работе различия обычно такие:
- ORM: самый быстрый старт для простого create, read, update, delete
- Query builder: более понятная логика запросов без ручной сборки SQL-строк
- Raw SQL: полный контроль над joins, индексами, CTE, window functions и нестандартными случаями
Небольшой пример делает это понятнее. Допустим, вам нужен поиск клиентов с фильтрами по статусу, дате регистрации и последней сумме заказа. В ORM это часто начинается аккуратно, а потом становится неудобным, когда добавляются кастомная сортировка, агрегаты или функции, зависящие от конкретной базы. В query builder запрос обычно остаётся читаемым, потому что структура SQL всё ещё видна в Go-коде. В raw SQL такой statement проще всего понять, если запрос уже сложный.
Этот выбор также влияет на то, кому будет комфортно работать в кодовой базе. Команды с более сильными SQL-навыками часто предпочитают query builder или raw SQL, потому что видят, что именно сделает база. Команды, где больше продуктовых инженеров, могут быстрее двигаться с ORM, хотя бы для рутинной работы.
Большинству команд не нужно выбирать один инструмент на все случаи. Практичный вариант — один основной подход и один запасной. Например, используйте query builder для большинства путей чтения и записи, а в raw SQL переходите для отчётов, поиска и всего, что чувствительно к производительности.
Простой способ выбрать
Начните с запросов, которые важнее всего для вашего продукта, а не с личных предпочтений. Страница настроек с базовым create, read, update и delete — это одно. Отчёт по биллингу с joins, итогами и массовыми обновлениями — совсем другое. Когда люди спорят про ORM vs query builder vs raw SQL in Go, они часто сравнивают инструменты в вакууме. Обычно это никуда не приводит.
Составьте короткий список запросов, от которых ваш продукт зависит каждый день. Подумайте о checkout-потоках, правах пользователей, поиске, отчётности, фоновых задачах и всём, что одновременно трогает много строк. Затем отметьте те, где есть joins, агрегаты или пакетные записи. Именно эти запросы покажут, где вам нужна простота, а где — контроль.
- Назовите человека, который будет разбирать медленные запросы, когда в production станет шумно.
- Отметьте запросы, где важен точный SQL.
- Выберите один основной вариант для обычной разработки.
- Оставьте один запасной выход для неудобных случаев.
- Проверьте подход на реальной задаче, прежде чем делать его правилом.
Первый пункт практичнее, чем кажется. Если в команде никто не хочет читать SQL, ORM может помочь быстрее стартовать, но работа с медленными запросами всё равно на кого-то ляжет. Если один-два человека уверенно работают с SQL и планами запросов, query builder или raw SQL могут сэкономить время позже, потому что код ближе к тому, что реально выполняет база.
Ещё подумайте о смене схемы. Если таблицы меняются каждую неделю, выбирайте подход, который позволяет легко вместе проверять миграции и обновления запросов. Сложная абстракция мало помогает, если каждая миграция превращается в поиск по моделям, тегам, сгенерированному коду и ручным правкам.
Потом проверьте выбор на реальной задаче, а не на игрушечном примере. Сделайте один endpoint, один отчёт или одну batch-задачу. Сразу станет видна трение: нечитаемые запросы, слишком много шаблонного кода, слабая типобезопасность или сложные для проверки миграции. Такой маленький тест скажет больше, чем длинный спор в команде.
Сначала подумайте о миграциях
Миграции обычно помогают принять это решение быстрее, чем стиль запросов. Если вашей команде сложно читать изменения схемы на ревью, ORM может превратить небольшое изменение таблицы в шумный diff, который никто не хочет проверять внимательно. Raw SQL писать дольше, но на ревью видно, что именно меняется в базе.
Query builder сам по себе миграции обычно не решает. Многие команды Go используют builder для запросов приложения, а миграции по-прежнему хранят как обычные SQL-файлы. Такое разделение часто удобнее, потому что история схемы остаётся понятной, даже если приложение использует более высокоуровневые помощники.
Понятность важнее автоматизации
Посмотрите, как команда уже работает. Люди проверяют миграции построчно или доверяют сгенерированному результату и идут дальше? Если ревьюер не может за минуту заметить удалённый индекс, переименованную колонку или изменение значения по умолчанию, процесс слишком непрозрачен.
Сгенерированные diff’ы требуют особой осторожности. В них могут попадаться перестановка колонок, непреднамеренные изменения значений по умолчанию или детали, зависящие от конкретного движка, которые только засоряют ревью. Такие сюрпризы отнимают время, а ещё хуже — приучают людей переставать читать внимательно.
Правила отката тоже должны где-то жить. Одни команды держат SQL для отката рядом с каждой прямой миграцией. Другие пишут только односторонние миграции и восстанавливаются из бэкапов, если что-то идёт не так. Оба варианта рабочие, но выбрать нужно заранее и придерживаться одного подхода.
Помогает простой чек-лист:
- Может ли новый член команды прочитать миграцию шестимесячной давности и понять её?
- Понятно ли ревьюеру, что происходит при откате?
- Можно ли безопасно прогнать изменение в staging с данными, похожими на production?
- Можно ли отделить изменения схемы от исправлений данных?
Последний пункт важнее, чем многие ожидают. Добавить колонку — это один тип изменения. Заполнить значениями десять миллионов строк — совсем другой. Если разделить эти шаги, деплои станут проще, откаты — чище, а ночных сюрпризов будет меньше.
Если команде нужен жёсткий контроль, держите миграции близко к raw SQL, даже если приложение использует ORM или query builder. Это скучный выбор, и чаще всего именно он оказывается правильным.
Заранее планируйте отладку производительности
Слой базы кажется простым, когда трафика мало. Проблемы начинаются, когда один endpoint становится медленным, а никто не может понять, какой именно запрос виноват, как часто он выполнялся и что реально получила база.
Обычный SQL проще всего смотреть в slow query logs, потому что текст в коде почти совпадает с тем, что выполняет база. Когда запрос занимает 800 мс, его можно скопировать, прогнать через EXPLAIN и посмотреть, где уходит время.
ORM это усложняет. Код может выглядеть чисто, но при этом тихо запускать N+1 pattern в фоне. Цикл, который загружает 50 записей, может превратиться в 51 запрос, и вы не заметите этого, пока пользователи не почувствуют задержку.
Query builder находится посередине. Он тоже добавляет слой, но большинство таких библиотек позволяет легко вывести итоговый SQL и связанные значения. Этого уже достаточно, чтобы быстрее разбирать тяжёлый endpoint под нагрузкой.
Простой пример: дашборд загружает аккаунты, а затем для каждого аккаунта подтягивает последние счета. В ORM это может выглядеть как аккуратные вызовы моделей, которые хорошо читаются на ревью. В production же это может завалить базу десятками лишних чтений. С raw SQL или query builder команды обычно быстрее замечают повторяющийся доступ, потому что количество запросов виднее.
Что измерять
Не останавливайтесь только на длительности запроса. Отслеживайте несколько метрик вместе:
- итоговый SQL для медленных запросов
- общее число запросов на один request
- время транзакции от begin до commit
- время ожидания connection pool
- сколько строк просканировано и сколько возвращено
Ожидание пула часто важнее, чем многие думают. Иногда план выполнения нормальный, но запросы всё равно тормозят, потому что все соединения заняты в долгих транзакциях. Это уже говорит о поведении приложения, а не только о настройке SQL.
Запускайте EXPLAIN для тяжёлых endpoint’ов заранее, до того как это превратится в пожар. Проверяйте пути, которые приносят деньги, отчёты, импорты и админские экраны с большим количеством фильтров. Именно они обычно ломаются первыми.
Если вашей команде нужен самый быстрый путь от
Сколько контроля на самом деле хочет ваша команда
В споре «ORM vs query builder vs raw SQL in Go» привычки команды часто решают больше, чем бенчмарки. Лучший вариант обычно тот, который люди могут читать, проверять и менять без догадок о том, что делает слой базы.
Одним командам важно, чтобы каждый join, фильтр и транзакция были прописаны явно. Обычно это ведёт к raw SQL. Вы видите точный запрос, можете рассуждать о блокировках и проверять каждое изменение в pull request. Если кому-то нужно чинить медленный отчёт в два часа ночи, явный SQL доверять гораздо проще, чем сгенерированным запросам, спрятанным за методами моделей.
Другим командам важнее быстро выпускать типовые экраны. Админки, страницы настроек и обычный CRUD часто не требуют ручного SQL с первого дня. ORM может убрать много повторяющегося кода. Эта скорость реальна, но только если команда принимает компромисс: меньше контроля над формой запроса, больше правил фреймворка и больше работы, когда простая страница превращается в сложный набор joins.
Query builder стоит посередине. Он даёт структуру и более безопасные шаблоны, но вы всё равно выбираете joins, столбцы и поток транзакции. Для новых Go-команд это часто самый удобный баланс. Люди могут придерживаться одного стиля, не разбрасывать SQL-строки по кодовой базе и при этом понимать, что именно делает база.
Потребности в аудитe и ревью тоже важнее, чем думают многие команды. Если компании нужно объяснять, кто изменил запрос, почему транзакция охватывает три записи или как именно отчёт считает цифры, явные запросы проверять проще. Команды, которые используют автоматическое code review и AI-инструменты для разработки, часто приходят к тому же выводу: понятный SQL проверять легче, чем поведение ORM, размазанное по тегам, hooks и вспомогательным методам моделей.
Хорошо работает простое правило:
- Используйте raw SQL, когда важнее всего контроль и отладка.
- Используйте query builder, когда нужна структура без потери дизайна запросов.
- Используйте ORM, когда важнее скорость на обычном CRUD, чем тонкий контроль.
- При необходимости смешивайте подходы, но границы задайте заранее.
Последний пункт особенно важен. Команда может жить почти с любым выбором. Хаос начинается, когда никто не согласен, какие части приложения заслуживают полного контроля над запросом, а какие — нет.
Реалистичный пример из растущего продукта
Стартап SaaS начинает с небольшого сервиса на Go и одной базы Postgres. Сначала команде нужны только простые админские экраны: редактировать клиентов, исправлять счета и менять названия тарифов. На этом этапе ORM подходит хорошо, потому что большинство запросов связаны с одной таблицей, формы меняются часто, а команде нужно двигаться быстро.
Схема на раннем этапе тоже часто меняется. Миграции базы данных в Go проще сопровождать, пока модель данных ещё проста, и разработчикам не приходится вручную писать каждый запрос для каждого экрана.
Через несколько месяцев support просит поиск клиентов. Финансы хотят историю биллинга с фильтрами по диапазону дат, тарифу, статусу оплаты и владельцу аккаунта. Теперь те же условия появляются на нескольких страницах списков и в одном API endpoint.
Именно здесь помогает query builder. Команда может переиспользовать одни и те же фильтры, добавлять необязательные условия без грязной сборки строк и держать логику запроса в одном месте. Это хороший промежуточный вариант, когда разработчикам нужен больший контроль над запросами в Go, но они не хотят писать каждый statement с нуля.
Потом появляется задача экспорта. Product manager хочет CSV со строками счетов, возвратами, купонами и налогами за последние 18 месяцев. Эта задача работает в фоне, читает много строк и обычно первой показывает, где начинается боль с производительностью.
Raw SQL часто оказывается самым простым способом решить такую задачу. Разработчики могут посмотреть точный statement, прогнать plan выполнения, добавить индекс, сократить набор выбираемых колонок или разбить экспорт на батчи, если он слишком долгий. Отладка SQL-производительности в Go намного проще, когда запрос явный.
Обычно лучше всего работает смешанная схема:
- Используйте ORM для простых админских форм и обычного CRUD.
- Используйте query builder для общих фильтров и списков со множеством необязательных условий.
- Используйте raw SQL для экспортов, отчётов и запросов, которые, скорее всего, придётся оптимизировать вручную.
Такой раздел даёт команде скорость в обычной продуктовой работе и контроль там, где медленные запросы могут навредить клиентам или внутренним командам.
Ошибки, которые позже создают боль
Команды обычно жалеют не о первом выбранном инструменте, а о правилах, которые придумали потом. Проблемы начинаются, когда они решают, что один слой должен обслуживать вообще все запросы.
ORM может отлично работать для insert, update и простых чтений. Потом появляются отчёты, поиск или batch-задачи, а команда продолжает тянуть тот же слой, хотя он уже перестал помогать. Код становится труднее читать, а SQL — труднее проверять.
Привычки к миграциям могут навредить ещё сильнее. Прятать изменения схемы внутри запуска приложения кажется удобным, когда один сервис работает с одной базой, но со временем это сильно устаревает.
Позже деплои начинают обгонять друг друга, время старта становится непредсказуемым, а одна неудачная миграция может заблокировать весь релиз. Держите миграции явными, версионированными и отдельными от кода, который отвечает на запросы.
Отладка производительности часто начинается слишком поздно. Если никто не смотрит на планы запросов, пока production не замедлится, первый настоящий урок приходит в самый неподходящий момент.
Команды часто тратят часы на тонкую настройку мелких lookup-запросов, потому что они выглядят простыми. А между тем один плохой join, один отсутствующий индекс или один случайный full scan делает большую часть вреда на каждом запросе.
Raw SQL создаёт другой вид боли. Он даёт полный контроль, но этот контроль нужно заслужить тестами.
Если не проверять сопоставление результата, небольшие изменения схемы могут сломать scans, обработку NULL или aliases столбцов без особых предупреждений. В Go одно несоответствие типа поля может превратить обычный запрос в раздражающий поиск ошибки.
Растущему продукту обычно нужен mix. Один сервис на Go может использовать ORM для CRUD в админке, query builder для поисковых фильтров и raw SQL для отчётов или массовых задач. Это нормально. Ошибка — делать вид, что команда обязана навсегда выбрать только один стиль.
Перед тем как влить новый подход, проверьте несколько вещей:
- Этот запрос действительно подходит инструменту, или вы его проталкиваете силой?
- Можно ли прогонять миграции до того, как приложение начнёт принимать запросы?
- Кто-то смотрел plan выполнения с реалистичным количеством строк?
- Тесты покрывают scans, NULL и переименованные колонки?
- Вы оптимизируете именно медленный запрос, а не тот, который легче всего подкрутить?
Короткая проверка перед тем, как принять решение
Выбирайте инструмент, когда команда спокойна, но оценивайте его по тому, что происходит в плохой день. Медленный endpoint, сломанный deploy или миграция, которая затронула не ту колонку, скажут вам больше, чем любое красивое демо.
Проведите одну небольшую проверку перед решением. Возьмите реальный endpoint и попросите коллегу ответить на несколько простых вопросов за 10 минут: какой SQL выполняется, где хранится история миграций, как воспроизвести один медленный запрос и как заменить один горячий путь на ручной SQL. Если это превращается в детектив, инструмент скрывает слишком много.
Схема обычно здоровая, когда верны несколько вещей:
- Команда может пройти путь от Go-кода до итогового SQL, не пробираясь через лишние уровни косвенности.
- Новый член команды может безопасно добавить, запустить и откатить маленькую миграцию в первый день.
- Когда один endpoint начинает тормозить, логи и трассировки указывают на один запрос и его входные данные.
- Вы можете вставить raw SQL в один дорогой путь, не переписывая весь слой данных.
- Ревьюеры ясно читают изменения схемы и замечают рискованные удаления, переименования или блокировки.
Лучший ответ на вопрос ORM vs query builder vs raw SQL in Go часто тот, который ломается самым очевидным образом. Скрытая магия в начале кажется приятной. Позже она съедает часы.
Если вашей команде нужен жёсткий контроль над запросами, query builder или raw SQL обычно упрощают отладку. Вы видите, что выполняется, и code review остаётся честным. Если команда в основном выпускает стандартные CRUD-экраны и лишь иногда пишет кастомные запросы, ORM всё ещё может подойти, но только если все могут без труда посмотреть на сгенерированный SQL.
Последняя проверка простая и очень полезная. Попросите нового инженера проследить запрос от handler до query и до migration. Если он может сделать это за первую неделю, значит, схема, скорее всего, нормальная. Если ему нужен кто-то, кто объяснит скрытые правила, эта боль будет только расти вместе с кодовой базой.
Следующие шаги для вашей команды
Команды застревают в споре ORM vs query builder vs raw SQL in Go, когда пытаются навязать одно правило вообще для всех запросов. Обычно это даёт обратный эффект. Лучше заранее решить, где подходит каждый подход, проверить его на реальной работе и записать несколько командных правил до того, как привычки станут нормой.
У небольшой продуктовой команды часто есть три очень разные задачи: обычные CRUD-экраны, отчётные запросы и фоновые задачи, которые трогают много строк. Им не нужен один и тот же инструмент. Если это записать заранее, вы избежите недель споров позже.
Простой первый шаг выглядит так:
- Выберите один основной подход для обычного CRUD, чтобы новый код оставался единообразным.
- Решите, могут ли отчёты обходить этот стандарт, когда запросы становятся сложными.
- Задайте правила для фоновых задач, особенно для batch updates, повторных попыток и долгих запросов.
- Договоритесь, когда raw SQL разрешён без отдельного обсуждения.
После этого проведите короткий spike с двумя лучшими вариантами. Не усложняйте. Соберите один реальный CRUD-поток, один отчёт с joins и фильтрами и одну задачу, которая обновляет данные батчами. Засеките, сколько времени уходит на написание, тестирование и отладку каждой версии. Большинство команд узнаёт за два дня такого упражнения больше, чем за десять встреч.
Пока тест свежий, напишите короткий style guide. Он не обязан быть красивым. Одной страницы достаточно, если на ней есть правила именования миграций, проверки разрушительных изменений, место хранения query logs и способ, которым инженеры сохраняют медленный SQL во время инцидентов в production. Эта страница сэкономит много повторяющихся споров.
Если команда быстро растёт или унаследовала старый багаж по базе данных, поможет внешний взгляд. Oleg Sotnikov работает как Fractional CTO и помогает стартапам и небольшим компаниям с Go-архитектурой, инфраструктурой и практичной настройкой команды. Он может разобрать компромиссы, проверить процесс миграций и помочь выбрать такую схему, с которой команда действительно сможет жить.
К концу такого spike у вас должен быть основной вариант для повседневных запросов, понятное правило исключения для сложного SQL и единый стандарт для миграций и логирования, которого придерживается вся команда.