Хотелось бы сразу сделать небольшой дисклеймер. Это не супер-экспертная статья. Скажем так - это мой инсайд о том, как еще можно работать с SQL запросами в Go проекте. В этой статье я расскажу о том как удобно хранить запросы в embeded sql файлах. Решение о котором я буду писать - лишь один из инструментов в арсенале разработчика, а не универсальное решение призванное вылечить все болезни. Надеюсь тебе это будет полезно.

При работе с базой данных в Go-приложениях я использовал несколько подходов к организации SQL-запросов:

  1. Query Builder

  2. Inline SQL

  3. Каждый запрос в отдельном embeded sql файле

  4. Группировка в файле

В этой статье опишу последний, паттерн группировки SQL-запросов. Это подход, где все запросы одной сущности хранятся в одном .sql файле с именованными секциями. Вместо того чтобы хранить каждый запрос в отдельном файле или писать SQL прямо в коде, ты:

  1. Группируешь все запросы сущности в один файл (например, department.sql)

  2. Помечаешь каждый запрос именем через комментарий -- name: QueryName

  3. Загружаешь файл один раз при старте приложения через go:embed

  4. Получаешь нужный запрос по имени через getter

Реализация

Парсер SQL-файлов

package queries

import (
	"embed"
	"errors"
	"fmt"
	"regexp"
	"strings"
)

// Директива go:embed встраивает все .sql файлы из текущей директории
// в бинарник при компиляции. Файлы будут доступны через sqlFiles.
//go:embed *.sql
var sqlFiles embed.FS

// Регулярное выражение для поиска маркеров вида "-- name: {{queryName}}"
// (?m) - многострочный режим, ^ соответствует началу каждой строки
var queryNameRegex = regexp.MustCompile(`(?m)^--\s*name:\s*(\w+)\s*$`)

// Queries хранит распарсенные SQL-запросы в виде map[имя]запрос
type Queries struct {
	queries map[string]string
}

// MustLoad загружает SQL-файл и паникует при ошибке.
// Используй на уровне пакета для fail-fast при старте приложения.
func MustLoad(filename string) *Queries {
	q, err := Load(filename)
	if err != nil {
		panic(err)
	}
	return q
}

// Load загружает и парсит SQL-файл, возвращая структуру с запросами.
// Читает файл из embedded FS и разбивает на именованные запросы.
func Load(filename string) (*Queries, error) {
	// Читаем содержимое файла из встроенной файловой системы
	content, err := sqlFiles.ReadFile(filename)
	if err != nil {
		return nil, fmt.Errorf("read sql file %s: %w", filename, err)
	}

	// Парсим содержимое, разбивая на отдельные запросы по маркерам
	queries, err := parse(string(content))
	if err != nil {
		return nil, fmt.Errorf("parse sql file %s: %w", filename, err)
	}

	return &Queries{queries: queries}, nil
}

// Get возвращает SQL-запрос по имени.
// Паникует если запрос не найден - это защита от опечаток в runtime.
func (q *Queries) Get(name string) string {
	query, ok := q.queries[name]
	if !ok {
		panic(fmt.Sprintf("query %q not found", name))
	}
	return query
}

// parse разбирает содержимое SQL-файла на отдельные именованные запросы.
// Ищет маркеры "-- name: X" и извлекает текст между ними.
func parse(content string) (map[string]string, error) {
	queries := make(map[string]string)

	// Находим все маркеры "-- name: X" с их позициями в тексте
	matches := queryNameRegex.FindAllStringSubmatchIndex(content, -1)
	if len(matches) == 0 {
		return nil, errors.New("no queries found")
	}

	// Проходим по каждому найденному маркеру
	for i, match := range matches {
		// Извлекаем имя запроса из группы захвата регулярки
		nameStart, nameEnd := match[2], match[3]
		name := content[nameStart:nameEnd]

		// Текст запроса начинается сразу после маркера
		queryStart := match[1]
		var queryEnd int

		// Запрос заканчивается там, где начинается следующий маркер
		// или в конце файла, если это последний запрос
		if i+1 < len(matches) {
			queryEnd = matches[i+1][0]
		} else {
			queryEnd = len(content)
		}

		// Убираем лишние пробелы и сохраняем запрос
		query := strings.TrimSpace(content[queryStart:queryEnd])
		queries[name] = query
	}

	return queries, nil
}

SQL-файл с запросами

-- name: Create
INSERT INTO departments (name, description, parent_id)
VALUES ($1, $2, $3)
RETURNING id, created_at, updated_at;

-- name: GetByID
SELECT id, name, description, parent_id, created_at, updated_at
FROM departments
WHERE id = $1;

-- name: GetAll
SELECT id, name, description, parent_id, created_at, updated_at
FROM departments
ORDER BY name;

-- name: Update
UPDATE departments
SET name = $2, description = $3, parent_id = $4, updated_at = NOW()
WHERE id = $1
RETURNING updated_at;

-- name: Delete
DELETE FROM departments WHERE id = $1;

Использование в репозитории

package postgresql

import (
	"context"

	"github.com/dsbasko/team-pulse/internal/domain"
	"github.com/dsbasko/team-pulse/internal/repositories/postgresql/queries"
	"github.com/google/uuid"
)

// Загружаем все запросы при инициализации пакета.
// MustLoad паникует при ошибке - приложение не запустится с битым SQL.
// Это происходит один раз при старте, потом запросы берутся из памяти.
var departmentQueries = queries.MustLoad("department.sql")

// DepartmentRepository - репозиторий для работы с департаментами.
// Встраивает BaseRepository для переиспользования общей логики.
type DepartmentRepository struct {
	*BaseRepository
}

// NewDepartmentRepository создает новый репозиторий.
// Принимает интерфейс DB, а не конкретный тип - это позволяет
// подставлять моки в тестах.
func NewDepartmentRepository(db DB) *DepartmentRepository {
	return &DepartmentRepository{
		BaseRepository: NewBaseRepository(db),
	}
}

// Create создает новый департамент в БД.
// Возвращает сгенерированные поля обратно в структуру.
func (r *DepartmentRepository) Create(ctx context.Context, dept *domain.Department) error {
	const op = "DepartmentRepository.Create" // Для контекста в ошибках

	// departmentQueries.Get("Create") возвращает SQL-строку из файла.
	// Аргументы передаются в том же порядке, что и $1, $2, $3 в запросе.
	err := r.db.QueryRow(ctx, departmentQueries.Get("Create"),
		dept.Name,
		dept.Description,
		dept.ParentID,
	).Scan(
		&dept.ID,
		&dept.CreatedAt,
		&dept.UpdatedAt,
	)
	if err != nil {
		return WrapError(op, err)
	}

	return nil
}

// GetByID возвращает департамент по UUID.
// Возвращает ошибку если департамент не найден.
func (r *DepartmentRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.Department, error) {
	const op = "DepartmentRepository.GetByID"

	// Выполняем запрос с параметром id
	rows, err := r.db.Query(ctx, departmentQueries.Get("GetByID"), id)
	if err != nil {
		return nil, WrapError(op, err)
	}

	// ScanOne - хелпер, который сканирует одну строку или возвращает ErrNoRows
	department, err := ScanOne(rows, scanDepartment)
	if err != nil {
		return nil, WrapError(op, err)
	}

	return &department, nil
}

// GetAll возвращает все департаменты.
// Если департаментов нет - возвращает пустой слайс, не ошибку.
func (r *DepartmentRepository) GetAll(ctx context.Context) ([]domain.Department, error) {
	const op = "DepartmentRepository.GetAll"

	// Запрос без параметров - получаем все записи
	rows, err := r.db.Query(ctx, departmentQueries.Get("GetAll"))
	if err != nil {
		return nil, WrapError(op, err)
	}

	// ScanMany - хелпер для сканирования нескольких строк в слайс
	return ScanMany(rows, scanDepartment)
}

Структура проекта

internal/repositories/postgresql/
├── queries/             # Директория с SQL-файлами
│   ├── embed.go         # Парсер: go:embed + regex + Load/Get
│   ├── department.sql   # Все запросы для таблицы departments
│   ├── team.sql         # Все запросы для таблицы teams
│   ├── employee.sql     # Все запросы для таблицы employees
│   └── project.sql      # Все запросы для таблицы projects
├── department.go        # Использует departmentQueries.Get("X")
├── team.go              # Использует teamQueries.Get("X")
├── employee.go          # Использует employeeQueries.Get("X")
└── project.go           # Использует projectQueries.Get("X")

# Принцип: один .sql файл = один .go репозиторий = одна таблица/сущность

Преимущества подхода

1. Чистый Go-код

Репозиторий содержит только логику работы с данными, без SQL-строк:

// ❌ SQL-запрос прямо в коде
rows, err := r.db.Query(ctx, `
    SELECT id, name, description, parent_id, created_at, updated_at
    FROM departments
    WHERE id = $1
`, id)

// ✅ Запрос загружается из .sql файла по имени
rows, err := r.db.Query(ctx, departmentQueries.Get("GetByID"), id)

2. SQL с подсветкой синтаксиса

IDE распознаёт .sql файлы и предоставляет:

  • Подсветку синтаксиса

  • Автодополнение

  • Проверку ошибок

  • Форматирование

3. Группировка по сущности

Все запросы одной сущности в одном файле - легко найти и модифицировать.

4. Компиляция в бинарник

go:embed встраивает SQL-файлы в исполняемый файл:

  • Нет зависимости от внешних файлов

  • Нет риска потерять SQL-файлы

  • Ошибка парсинга = ошибка компиляции

5. Fail-fast при старте

MustLoad и panic в Get гарантируют, что:

  • Ошибки в SQL-файлах обнаруживаются сразу при старте

  • Опечатки в именах запросов не дойдут до production

6. Лёгкое тестирование

Можно тестировать SQL-запросы изолированно от Go-кода.

Сравнение подходов

Query Builder (squirrel)

// Query Builder строит SQL программно через цепочку методов.
// Плюс: можно динамически добавлять условия (if needFilter { .Where(...) })
// Минус: сложнее читать, нет подсветки SQL, overhead на построение
query, args, _ := sq.
    Select("id", "name", "description", "parent_id", "created_at", "updated_at").
    From("departments").
    Where(sq.Eq{"id": id}).
    PlaceholderFormat(sq.Dollar).
    ToSql()

Когда использовать: Динамические запросы с условиями, фильтрация по разным полям.

Inline SQL

// Inline SQL - запрос хранится как константа прямо в Go-коде.
// Плюс: всё в одном месте, не нужен парсер
// Минус: нет подсветки SQL, захламляет код при большом количестве запросов
const getDepartmentByID = `
    SELECT id, name, description, parent_id, created_at, updated_at
    FROM departments
    WHERE id = $1
`

Когда использовать: Простые проекты, прототипы, один-два запроса.

Файл на запрос

# Каждый SQL-запрос в отдельном файле.
# Плюс: изоляция, удобно для очень длинных запросов
# Минус: много файлов, сложная навигация при 50+ запросах
queries/
├── department_create.sql      # INSERT запрос
├── department_get_by_id.sql   # SELECT по ID
├── department_get_all.sql     # SELECT всех записей
├── department_update.sql      # UPDATE запрос
└── department_delete.sql      # DELETE запрос

Когда использовать: Очень сложные запросы на 100+ строк с CTE и подзапросами.

Группировка в файле (наш подход)

# Все запросы одной сущности в одном файле с маркерами -- name: X
# Плюс: баланс между организацией и простотой
# Минус: нужен парсер для разделения запросов
queries/
└── department.sql  # Create, GetByID, GetAll, Update, Delete - всё здесь

Когда использовать: Большинство реальных проектов с типовыми CRUD-операциями.

Для себя я вывел несколько best practices, которые позволяют удобно работать с этим подходом. Перечислю то что помню:

1. Именование запросов

Используй глаголы в PascalCase:

  • Create, GetByID, GetAll, Update, Delete

  • GetByEmail, GetActiveUsers, CountByStatus

2. Один файл = одна сущность

Не спешивай запросы разных таблиц в одном файле (не или хотябы старайся).

3. Комментарии для сложных запросов

-- name: GetEmployeesWithMetrics
-- Описание: Возвращает сотрудников с агрегированными метриками за период.
-- Используется в: отчёты, дашборды, аналитика команды
-- Параметры:
--   $1 = team_id (UUID команды)
--   $2 = start_date (начало периода)
--   $3 = end_date (конец периода)
SELECT
    e.id,
    e.name,
    -- Считаем уникальные коммиты сотрудника за период
    COUNT(DISTINCT c.id) as commit_count,
    -- Считаем уникальные merge request'ы за период
    COUNT(DISTINCT mr.id) as mr_count
FROM employees e
-- LEFT JOIN чтобы показать сотрудников даже без коммитов
LEFT JOIN commits c ON c.author_id = e.id
    AND c.created_at BETWEEN $2 AND $3  -- Фильтр по периоду
LEFT JOIN merge_requests mr ON mr.author_id = e.id
    AND mr.created_at BETWEEN $2 AND $3
WHERE e.team_id = $1  -- Фильтр по команде
GROUP BY e.id, e.name;  -- Группировка для агрегатных функций

4. Валидация при старте

Используйте MustLoad вместо Load для критичных запросов:

// MustLoad вызывает panic() если:
// - файл не найден
// - файл не содержит ни одного маркера "-- name: X"
// - ошибка чтения файла
//
// Это гарантирует fail-fast: приложение упадёт при старте,
// а не в runtime когда пользователь попытается выполнить запрос.
// Лучше узнать о проблеме сразу, чем в 3 часа ночи на проде.
//
// Можно также добавить валидацию самого SQL при желании.
var queries = queries.MustLoad("department.sql")

В общем, пора заканчивать статью! Паттерн группировки SQL-запросов - это золотая середина между полным контролем над SQL и чистотой Go-кода. Он особенно хорош для проектов с классическим CRUD и команд где разработчики пишут SQL вручную.

Философия выбора

Хороший разработчик не привязывается к одному инструменту, а понимает компромиссы каждого.

Вопросы, которые стоит задать себе:

  1. Насколько динамичны мои запросы?

  2. Важнее контроль над SQL или скорость разработки?

  3. Какой уровень экспертизы в SQL у команды?

  4. Как часто меняются требования к запросам?

Правильный ответ всегда зависит от контекста. Паттерн группировки хорошо работает для стабильных CRUD-операций, но для сложной бизнес-логики с динамическими фильтрами может потребоваться комбинация подходов.

Не бойся использовать разные инструменты в одном проекте:

  • Embedded SQL для стандартных операций

  • Query Builder для динамических отчётов

  • Raw SQL для критичных по производительности запросов

Главное - осознанный выбор, а не слепое следование шаблонам.

Спасибо за уделенное время, надеюсь материал был полезен :-)

Кстати, веду небольшой дневник в телеге, вдруг кому интересно...

Комментарии (15)


  1. Rathil
    21.12.2025 12:26

    Что мешает вынести в отдельный файл со списком констант? Так ты получишь такой же подход. Когда у тебя SQL в отдельном файле, как ты и хотел.

    На счёт подсветки синтаксиса, сейчас уже редакторы умеют подсветить синтаксис внутри строковых переменных.

    Но так тебе не нужно парсить файл, доставать запрос оттуда, надеяться, что другой разработчик не напутали с комментарием для нового запроса.

    Так же я не понял, как у тебя ошибка в парсинге файла получилась ошибкой компиляции?


    1. dsbasko Автор
      21.12.2025 12:26

      Константы в Go-файле - валидный подход. Но в чистом .sql файле работает полноценный SQL-тулинг: форматирование через pg_format, линтинг через sqlfluff, автодополнение колонок при подключении IDE к БД.

      Парсер и риск ошибок. Регулярка тривиальная, 5 строк. Если разработчик забудет маркер, запрос не попадёт в map, и Get() упадёт с panic при первом же вызове. Ошибка не пройдёт незамеченной.

      Ошибка компиляции. Тут я возможно действительно преувеличил. go:embed даст ошибку компиляции только если файл не найден. Однако никто кто мешает прикрутить нормальную валидацию SQL при инициализации пакета. Все это выходит на рамки простой статьи.

      Как я писал в самом начали в конце статьи: это не единственный, да и не самый лучший способ организации работы с SQL файлами. Однако в большинстве случаев на моей практике он самый удобный.


      1. Rathil
        21.12.2025 12:26

        Из всего перечисленного Вами, я не уверен только в линтере SQL, используя константы. Всё остальное работает и на константах. Но при этому у вас избавляется необходимость в чтении файла, эмбединга и всё значительно упрощается (как минимум находясь в функции запроса, легко перейти на сам SQL и изучить/изменить его).


  1. Uolis
    21.12.2025 12:26

    А селективные процедуры в вашем SQL сервере ещё не изобрели? Просто если да, то сложные запросы где то там, с подсветкой, проверкой, всем этим. А в Го только довольно простейшие запросы к ним.


    1. dsbasko Автор
      21.12.2025 12:26

      Хранимые процедуры легитимный подход, но со своими компромиссами. Логика размазывается между приложением и БД. Для меня это усложняет отладку и понимание системы целиком.

      Версионирование процедур отдельная боль. Нужно синхронизировать миграции БД с деплоем приложения.

      Тестирование тоже усложняется. Для unit-тестов репозитория теперь нужна реальная БД с актуальными процедурами. Плюс vendor lock-in: PL/pgSQL !== T-SQL !== MySQL процедуры.

      Странно что Не бойся использовать разные инструменты в одном проекте при чтении статьи проходит мимо глаз. Если у вас команда DBA сильная и процедуры стандарт, отлично. Embedded SQL для тех, кто хочет держать всю логику в Go и деплоить одним бинарником.


      1. Uolis
        21.12.2025 12:26

        >Логика размазывается между приложением и БД

        Наоборот, логика существует в единственном месте, где и положено, в БД.

        >Странно что Не бойся использовать разные инструменты в одном проекте при чтении статьи проходит мимо глаз

        Наоборот, именно это и бросается в глаза. Вся логика в БД, и все инструменты доступа просто доступ, а не заменяют собой БД.


    1. stvoid
      21.12.2025 12:26

      Ммм... как будто бы напрашивается идея с тем, чтобы DBA вели просто свой "пакет"/репозиторий с набором таких вот *.sql файлов, который подтягивать в нужное место через обертку в виде go.mod или сабмодулем.
      На вскидку звучит интересно...
      В конце концов существуют же кошмарные репозитории гугла с адовыми обертками для go и остальных :))
      Ну что то типа такого https://github.com/google/brotli - кажется идея с аналогичной репой для sql звучит не так ужасно :)))


  1. stvoid
    21.12.2025 12:26

    Делал похожим образом в одном специфичном внутреннем проекте, только использовал text/template т.к. нужно было хитро подставлять значения, или другие кусочки sql.
    Оно существовало (и вроде как существует) как проект переноса легаси с питона.
    Опять же, получается +- чище, в git удобнее отслеживать изменения, если затрагивались именно SQL запросы, ну и прочие полезные фишки типа прокидывания функций в template и т.д и т.п.
    Примерно так выглядел какой нибудь маленький кусочек из множества

    SELECT DISTINCT
        CADNUM_PARENT
    FROM
        unio.T_HISTORICITY_LINK
    WHERE
        CADNUM_PARENT != ANY( {{ joinedArgs `Cads` .Cads }} )
        AND CADNUM_CHILD = ANY( {{ joinedArgs `Cads` .Cads }} )
        AND CADNUM_CHILD != :cad_in
        AND CADNUM_PARENT != :cad_in
    


    1. dsbasko Автор
      21.12.2025 12:26

      Интересное решение. Даже не думал об этом :-)


    1. AbrekUS
      21.12.2025 12:26

      Тоже использую text/template для хранения SQL запросов отдельно от Go кода.

      Дополнительное удобство что можно хранить несколько версий sql/v1/*.sql, sql/v2/*.sql, и т.д. и выбирать нужную версию SQL запросов в рантайм.


    1. evgeniy_kudinov
      21.12.2025 12:26

      Вместо text/template можно использовать https://github.com/VauntDev/tqla
      А темплейты хранить в templates/*.sql.tpl например.


  1. fredrsf
    21.12.2025 12:26

    Есть же https://sqlc.dev/


    1. dsbasko Автор
      21.12.2025 12:26

      Да, sqlc отличный инструмент, но это разные весовые категории :-)

      sqlc парсит SQL, генерирует Go-структуры и методы. Даёт типобезопасность, но добавляет шаг сборки, зависимость от внешнего тула и генерируемый код в репозиторий.

      Мой же подход сейчас - это просто парсер на условно 50 строк кода. Ноль зависимостей, ноль генерации, полный контроль над кодом репозитория. Ты сам решаешь как сканировать результаты, какие хелперы использовать, как обрабатывать ошибки.

      В целом считаю sqlc отличным интснтументом. Если нужна типобезопасность и не смущает кодогенерация, sqlc это одно из лучших решений, тут сложно спорить. Если хочешь минимализм и полный контроль, то вполне хватит простого go:embed с парсером. Иногда (да по правде говоря чаще всего), этого вполне достаточно.

      Хороший разработчик не привязывается к одному инструменту, а понимает компромиссы каждого.


  1. manyakRus
    21.12.2025 12:26

    Такие простые запросы вообще писать не надо вручную,
    кодогенератор должен их написать сам,
    без ORM, на чистом pgx:
    https://github.com/ManyakRus/crud_generator


  1. MasterErik
    21.12.2025 12:26

    Вы реализовали маленькую часть MyBatis из java мира