Go: SQL

Теория: Структуры данных и маппинг

После выполнения запросов программе нужно преобразовать строки результата в удобные структуры Go. Такой маппинг определяет, как колонки из таблиц превращаются в поля структуры, как обрабатываются NULL-значения и каким образом считывается время.

Когда приложение читает данные из базы, оно связывает строки результата со структурами Go через Scan(). В запросе перечисляют колонки в нужном порядке, а Scan() заполняет поля структуры в таком же порядке. Программа получает одну строку или поток строк, и каждую строку она считывает в заранее описанную структуру, где типы совпадают с типами в базе.

type Product struct {
	ID        int64
	Name      string
	Price     int
	CreatedAt time.Time
}

func FindProduct(ctx context.Context, db *sql.DB, id int64) (Product, error) {
	var p Product

	// Порядок колонок в SELECT совпадает с порядком аргументов Scan.
	err := db.QueryRowContext(ctx, `
		SELECT id, name, price, created_at
		FROM products
		WHERE id = $1
	`, id).Scan(
		&p.ID,
		&p.Name,
		&p.Price,
		&p.CreatedAt,
	)

	return p, err
}

При чтении строк из базы все поля структуры получают значения из Scan(). Но не каждое поле в таблице гарантировано заполнено, поэтому программа должна уметь принимать NULL и отличать отсутствие данных от обычного значения.

Нулевые значения и типы sql.Null*

Когда в таблице есть колонка, которая может содержать NULL, у программы возникает простой вопрос: «А что я должен сюда читать, если данных нет?». Обычные типы Go — строки, числа, булевы значения — не умеют хранить отсутствие данных. Если попытаться сканировать NULL прямо в string или int, драйвер выдаст ошибку: он не знает, какое значение туда положить.

Чтобы такие ситуации обрабатывать безопасно, в пакете database/sql есть специальные контейнеры — sql.NullString, sql.NullInt64, sql.NullBool, sql.NullTime. Каждый такой тип хранит два поля: само значение и флаг Valid. Если колонка в базе действительно содержала данные, Valid будет true. Если пришёл NULL — значение остаётся пустым, а Valid становится false. Код может отличить: данных нет, или просто значение было пустым.

Но есть нюанс. В прикладном коде Go такие контейнеры используются всё реже. На практике почти всегда выбирают другой путь — сканируют значения в указатели на обычные типы. Если колонка не NULL, указатель получает значение. Если NULL — указатель остаётся nil. Такой подход проще, короче и органичнее выглядит в структуре.

Поэтому sql.Null* остаются скорее инструментом для особых случаев, когда нужны строгие правила работы с NULL. В обычной работе чаще используют обычные типы с указателями.

type User struct {
	ID       int
	Email    string
	Name     sql.NullString // колонка может быть NULL
	Age      sql.NullInt64  // тоже может быть NULL
	JoinedAt time.Time
}

func ListUsers(ctx context.Context, db *sql.DB) ([]User, error) {
	rows, err := db.QueryContext(ctx, `
		SELECT id, email, name, age, joined_at
		FROM users
	`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var out []User

	for rows.Next() {
		var u User

		// Если в колонке NULL, Scan присвоит Valid = false.
		if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Age, &u.JoinedAt); err != nil {
			return nil, err
		}

		out = append(out, u)
	}

	return out, rows.Err()
}

Помимо текстовых и числовых колонок в таблицах часто встречаются отметки времени. Драйвер считывает их в тип time.Time, но работа с датами требует учёта формата хранения и часовых поясов.

Работа со временем: time.Time и timestamp

Когда таблица содержит отметки времени, программа читает их в тип time.Time. База хранит TIMESTAMP или TIMESTAMPTZ, а драйвер превращает значение в структуру времени со смещением. Если колонка описана как TIMESTAMPTZ, в time.Time уже будет корректное смещение; если используется TIMESTAMP без зоны, драйвер интерпретирует его в локальном времени. При записи времени в базу программа может передавать time.Time напрямую, а при чтении выводить его в нужном формате через метод Format().

// Таблица с полем времени.
CREATE TABLE IF NOT EXISTS events (
	id SERIAL PRIMARY KEY,
	title TEXT NOT NULL,
	started_at TIMESTAMPTZ NOT NULL
);

ctx := context.Background()

// Вставка явного времени в UTC.
start := time.Date(2025, 11, 2, 19, 0, 0, 0, time.UTC)

_, err := db.ExecContext(ctx,
	`INSERT INTO events(title, started_at) VALUES($1, $2)`,
	"Вебинар по Go", start,
)
if err != nil {
	log.Fatal(err)
}

// Чтение события.
var (
	id   int
	name string
	when time.Time
)

err = db.QueryRowContext(ctx,
	`SELECT id, title, started_at FROM events WHERE title = $1`,
	"Вебинар по Go",
).Scan(&id, &name, &when)
if err != nil {
	log.Fatal(err)
}

// Форматирование для вывода.
fmt.Println("Начало:", when.Format(time.RFC3339))

Рекомендуемые программы

+7 800 100 22 47

бесплатно по РФ

+7 495 085 21 62

бесплатно по Москве

108813 г. Москва, вн.тер.г. поселение Московский,
г. Московский, ул. Солнечная, д. 3А, стр. 1, помещ. 20Б/3
ОГРН 1217300010476
ИНН 7325174845