シリーズ構成
- Go の基本文法
- CLI ツールを作る
- HTTP サーバーと REST API
- データベース連携(本記事)
- 実務的な周辺技術
- ポートフォリオを作る
- 実践知識を深める
この回の目標は 「PostgreSQL に接続し、安全で保守しやすいデータアクセス層を書けるようになる」 ことです。第3回のメモリストアを DB に置き換えます。
バックエンド開発の時間の多くはデータアクセスに費やされます。ここを丁寧に押さえることが、実務での生産性を直接決めます。
目次
- 準備:PostgreSQL を起動する
- スキーマとマイグレーション
- database/sql の全体像
- 接続とコネクションプール
- クエリの実行:Exec・QueryRow・Query
- NULL の扱い
- リポジトリ層の実装
- トランザクション
- リポジトリをインターフェースにして HTTP と繋ぐ
- sqlc:SQL からコード生成
- pgx をネイティブで使う
- ORM(GORM)との比較
- DB を含めたテスト
- パフォーマンスの基本
- よくある落とし穴
- 演習問題
1. 準備:PostgreSQL を起動する
Docker で立てるのが最も簡単です。
docker run -d --name pg \
-e POSTGRES_USER=app \
-e POSTGRES_PASSWORD=secret \
-e POSTGRES_DB=todo \
-p 5432:5432 \
postgres:16-alpine
# 接続確認
docker exec -it pg psql -U app -d todo -c "SELECT version();"
接続文字列(DSN)は環境変数に入れておきます。
export DATABASE_URL="postgres://app:secret@localhost:5432/todo?sslmode=disable"
MySQL を使いたい場合:ドライバを
github.com/go-sql-driver/mysqlに、プレースホルダを$1→?に変えるだけで、この記事の内容はほぼそのまま使えます。
2. スキーマとマイグレーション
スキーマは バージョン管理された SQL ファイル で管理します。「手で CREATE TABLE を打つ」は個人開発でも避けてください。
golang-migrate
brew install golang-migrate
# または
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
mkdir -p db/migrations
migrate create -ext sql -dir db/migrations -seq create_users
migrate create -ext sql -dir db/migrations -seq create_todos
db/migrations/000001_create_users.up.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
db/migrations/000001_create_users.down.sql
DROP TABLE users;
db/migrations/000002_create_todos.up.sql
CREATE TABLE todos (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 200),
description TEXT,
done BOOLEAN NOT NULL DEFAULT FALSE,
due_date DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_todos_user_id ON todos(user_id);
CREATE INDEX idx_todos_user_done ON todos(user_id, done);
db/migrations/000002_create_todos.down.sql
DROP TABLE todos;
migrate -database "$DATABASE_URL" -path db/migrations up
migrate -database "$DATABASE_URL" -path db/migrations version # 現在のバージョン
migrate -database "$DATABASE_URL" -path db/migrations down 1 # 1 つ戻す
マイグレーションの原則
- up と down を必ずペアで書く
- 一度適用したファイルは変更しない。修正は新しいマイグレーションで
- 本番では
downを安易に実行しない(データが消える) - 大きなテーブルのカラム追加は
NOT NULL DEFAULTの付け方でロック時間が変わる(PostgreSQL 11+ はデフォルト値つき追加が高速) - カラム名は
snake_case、時刻はTIMESTAMPTZ
Go からマイグレーションを実行する
アプリ起動時に自動適用したい場合はライブラリとして使えます。
import (
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
)
func runMigrations(dsn string) error {
m, err := migrate.New("file://db/migrations", dsn)
if err != nil {
return err
}
defer m.Close()
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return err
}
return nil
}
チーム開発では CI/CD のデプロイステップで実行するのが一般的です。
3. database/sql の全体像
database/sql は ドライバに依存しない共通インターフェース です。
アプリコード
↓
database/sql(標準)── 接続プール、トランザクション、プレースホルダ
↓
ドライバ(pgx / go-sql-driver/mysql / ...)
↓
DB
ドライバは ブランクインポート で登録します。
go get github.com/jackc/pgx/v5
import (
"database/sql"
_ "github.com/jackc/pgx/v5/stdlib" // init() で "pgx" ドライバが登録される
)
db, err := sql.Open("pgx", dsn)
主な型:
| 型 | 役割 |
|---|---|
*sql.DB | 接続プール。アプリ全体で 1 つ。goroutine 安全 |
*sql.Tx | トランザクション |
*sql.Rows | 複数行の結果。必ず Close |
*sql.Row | 1 行の結果 |
sql.Result | Exec の結果(影響行数) |
*sql.Stmt | プリペアドステートメント |
4. 接続とコネクションプール
sql.Open は 接続しません。最初のクエリ時に接続が張られます。起動時に Ping で確認します。
package repository
import (
"context"
"database/sql"
"fmt"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
func Open(ctx context.Context, dsn string) (*sql.DB, error) {
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
// プール設定
db.SetMaxOpenConns(25) // 同時接続の上限
db.SetMaxIdleConns(25) // 待機接続数
db.SetConnMaxLifetime(5 * time.Minute) // 接続の寿命(LB や DB 再起動対策)
db.SetConnMaxIdleTime(5 * time.Minute)
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("ping db: %w", err)
}
return db, nil
}
プールの考え方
*sql.DBは アプリ起動時に 1 つ作り、全体で共有 する。リクエストごとにOpenしないMaxOpenConnsは DB 側のmax_connections(PostgreSQL デフォルト 100)をアプリのインスタンス数で割った値以下にするMaxIdleConnsをMaxOpenConnsと同じにすると、接続の張り直しが減るConnMaxLifetimeを設定しないと、ネットワーク機器側で切られた接続を使い続けてエラーになることがある
ヘルスチェックに組み込む
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "db unavailable"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
5. クエリの実行:Exec・QueryRow・Query
3 つの使い分け
| メソッド | 用途 | 戻り値 |
|---|---|---|
ExecContext | INSERT / UPDATE / DELETE(結果行が不要) | sql.Result |
QueryRowContext | 1 行だけ取得 | *sql.Row |
QueryContext | 複数行取得 | *sql.Rows |
必ず Context 付きのメソッドを使います。リクエストがキャンセルされたらクエリも止まります。
プレースホルダは絶対
// ✗ SQL インジェクション
query := "SELECT * FROM users WHERE email = '" + email + "'"
// ✓ プレースホルダ(PostgreSQL は $1, MySQL は ?)
db.QueryRowContext(ctx, "SELECT * FROM users WHERE email = $1", email)
fmt.Sprintf で SQL を組み立てるコードを見たら、それがどんな理由でも直してください。
Exec
res, err := db.ExecContext(ctx,
`UPDATE todos SET done = TRUE, updated_at = NOW() WHERE id = $1 AND user_id = $2`,
id, userID,
)
if err != nil {
return fmt.Errorf("complete todo: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound // 対象がなかった
}
QueryRow
var t Todo
err := db.QueryRowContext(ctx,
`SELECT id, user_id, title, done, created_at FROM todos WHERE id = $1`, id,
).Scan(&t.ID, &t.UserID, &t.Title, &t.Done, &t.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Todo{}, ErrNotFound
}
if err != nil {
return Todo{}, fmt.Errorf("get todo %d: %w", id, err)
}
Scan の引数は SELECT の列と同じ順・同じ数 でなければなりません。SELECT * を使うとカラム追加で壊れるので、列名を明示 します。
Query
rows, err := db.QueryContext(ctx,
`SELECT id, user_id, title, done, created_at
FROM todos
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3`,
userID, limit, offset,
)
if err != nil {
return nil, fmt.Errorf("list todos: %w", err)
}
defer rows.Close() // 忘れると接続が返却されない
var todos []Todo
for rows.Next() {
var t Todo
if err := rows.Scan(&t.ID, &t.UserID, &t.Title, &t.Done, &t.CreatedAt); err != nil {
return nil, fmt.Errorf("scan todo: %w", err)
}
todos = append(todos, t)
}
if err := rows.Err(); err != nil { // ループ中のエラーを確認
return nil, fmt.Errorf("iterate todos: %w", err)
}
return todos, nil
rows.Close()、rows.Err()、Scan の順序 はテンプレートとして覚えます。
RETURNING で挿入した行を受け取る
var t Todo
err := db.QueryRowContext(ctx,
`INSERT INTO todos (user_id, title) VALUES ($1, $2)
RETURNING id, user_id, title, done, created_at`,
userID, title,
).Scan(&t.ID, &t.UserID, &t.Title, &t.Done, &t.CreatedAt)
PostgreSQL では RETURNING で INSERT と SELECT を 1 往復にできます。MySQL では LastInsertId() を使います。
6. NULL の扱い
Go の string に SQL の NULL は入りません。Scan するとエラーになります。
sql.Null 型
type Todo struct {
ID int64
Title string
Description sql.NullString // NULL 許容
DueDate sql.NullTime
}
// 読み取り
if t.Description.Valid {
fmt.Println(t.Description.String)
}
// 書き込み
desc := sql.NullString{String: "memo", Valid: true}
db.ExecContext(ctx, `UPDATE todos SET description = $1`, desc)
Go 1.22 からはジェネリックな sql.Null[T] もあります。
ポインタで受ける
JSON との相性が良いので、API 層に近いところではこちらが便利です。
type Todo struct {
Description *string `json:"description"` // nil → null
DueDate *time.Time `json:"due_date"`
}
var desc *string
rows.Scan(&t.ID, &desc) // NULL なら nil のまま
COALESCE で DB 側で解決
SELECT id, COALESCE(description, '') AS description FROM todos
方針:スキーマ設計時に なるべく NOT NULL DEFAULT にする。NULL が本当に意味を持つ(「未設定」と「空」を区別する)場合だけ NULL 許容にします。
7. リポジトリ層の実装
SQL を書く場所を リポジトリ に集約します。ハンドラやサービスから SQL が見えない状態を作ります。
// internal/repository/todo.go
package repository
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
type Todo struct {
ID int64
UserID int64
Title string
Description *string
Done bool
DueDate *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
var ErrNotFound = errors.New("not found")
type TodoRepository struct {
db *sql.DB
}
func NewTodoRepository(db *sql.DB) *TodoRepository {
return &TodoRepository{db: db}
}
const todoColumns = `id, user_id, title, description, done, due_date, created_at, updated_at`
// scanTodo は行を Todo に詰める共通処理
func scanTodo(s interface{ Scan(...any) error }) (Todo, error) {
var t Todo
err := s.Scan(&t.ID, &t.UserID, &t.Title, &t.Description, &t.Done,
&t.DueDate, &t.CreatedAt, &t.UpdatedAt)
return t, err
}
type CreateTodoParams struct {
UserID int64
Title string
Description *string
DueDate *time.Time
}
func (r *TodoRepository) Create(ctx context.Context, p CreateTodoParams) (Todo, error) {
row := r.db.QueryRowContext(ctx,
`INSERT INTO todos (user_id, title, description, due_date)
VALUES ($1, $2, $3, $4)
RETURNING `+todoColumns,
p.UserID, p.Title, p.Description, p.DueDate,
)
t, err := scanTodo(row)
if err != nil {
return Todo{}, fmt.Errorf("create todo: %w", err)
}
return t, nil
}
func (r *TodoRepository) Get(ctx context.Context, userID, id int64) (Todo, error) {
row := r.db.QueryRowContext(ctx,
`SELECT `+todoColumns+` FROM todos WHERE id = $1 AND user_id = $2`,
id, userID,
)
t, err := scanTodo(row)
if errors.Is(err, sql.ErrNoRows) {
return Todo{}, ErrNotFound
}
if err != nil {
return Todo{}, fmt.Errorf("get todo %d: %w", id, err)
}
return t, nil
}
type ListTodosParams struct {
UserID int64
Done *bool // nil なら絞り込みなし
Limit int
Offset int
}
func (r *TodoRepository) List(ctx context.Context, p ListTodosParams) ([]Todo, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT `+todoColumns+`
FROM todos
WHERE user_id = $1
AND ($2::boolean IS NULL OR done = $2)
ORDER BY created_at DESC, id DESC
LIMIT $3 OFFSET $4`,
p.UserID, p.Done, p.Limit, p.Offset,
)
if err != nil {
return nil, fmt.Errorf("list todos: %w", err)
}
defer rows.Close()
todos := make([]Todo, 0) // nil ではなく空スライス(JSON で [] にする)
for rows.Next() {
t, err := scanTodo(rows)
if err != nil {
return nil, fmt.Errorf("scan todo: %w", err)
}
todos = append(todos, t)
}
return todos, rows.Err()
}
func (r *TodoRepository) Count(ctx context.Context, userID int64, done *bool) (int, error) {
var n int
err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM todos WHERE user_id = $1 AND ($2::boolean IS NULL OR done = $2)`,
userID, done,
).Scan(&n)
return n, err
}
type UpdateTodoParams struct {
UserID int64
ID int64
Title string
Description *string
Done bool
DueDate *time.Time
}
func (r *TodoRepository) Update(ctx context.Context, p UpdateTodoParams) (Todo, error) {
row := r.db.QueryRowContext(ctx,
`UPDATE todos
SET title = $3, description = $4, done = $5, due_date = $6, updated_at = NOW()
WHERE id = $1 AND user_id = $2
RETURNING `+todoColumns,
p.ID, p.UserID, p.Title, p.Description, p.Done, p.DueDate,
)
t, err := scanTodo(row)
if errors.Is(err, sql.ErrNoRows) {
return Todo{}, ErrNotFound
}
if err != nil {
return Todo{}, fmt.Errorf("update todo %d: %w", p.ID, err)
}
return t, nil
}
func (r *TodoRepository) Delete(ctx context.Context, userID, id int64) error {
res, err := r.db.ExecContext(ctx,
`DELETE FROM todos WHERE id = $1 AND user_id = $2`, id, userID)
if err != nil {
return fmt.Errorf("delete todo %d: %w", id, err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
設計の要点
user_idを常に WHERE に含める:他人のデータを触れないようにする(IDOR 脆弱性対策)- 引数が 3 つ以上なら Params 構造体:順序ミスを防ぎ、拡張しやすい
$2::boolean IS NULL OR done = $2:任意フィルタを 1 つの SQL で実現する PostgreSQL の書き方- エラーには操作名と ID を含める:ログから何が失敗したか追える
sql.ErrNoRowsはリポジトリ内でErrNotFoundに変換:上の層がdatabase/sqlを import しなくてよい
ユーザーリポジトリ
// internal/repository/user.go
type User struct {
ID int64
Email string
PasswordHash string
CreatedAt time.Time
}
var ErrEmailTaken = errors.New("email already taken")
type UserRepository struct{ db *sql.DB }
func NewUserRepository(db *sql.DB) *UserRepository { return &UserRepository{db: db} }
func (r *UserRepository) Create(ctx context.Context, email, passwordHash string) (User, error) {
var u User
err := r.db.QueryRowContext(ctx,
`INSERT INTO users (email, password_hash) VALUES ($1, $2)
RETURNING id, email, password_hash, created_at`,
email, passwordHash,
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.CreatedAt)
if err != nil {
// UNIQUE 制約違反を判別する
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return User{}, ErrEmailTaken
}
return User{}, fmt.Errorf("create user: %w", err)
}
return u, nil
}
func (r *UserRepository) GetByEmail(ctx context.Context, email string) (User, error) {
var u User
err := r.db.QueryRowContext(ctx,
`SELECT id, email, password_hash, created_at FROM users WHERE email = $1`, email,
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrNotFound
}
return u, err
}
pgconn.PgError は github.com/jackc/pgx/v5/pgconn にあります。エラーコード 23505 は unique_violation。DB 制約をアプリのエラーに変換する定番パターンです。
8. トランザクション
複数の書き込みを すべて成功 or すべて失敗 にしたいときに使います。
基本形
func (r *TodoRepository) MoveAll(ctx context.Context, fromUser, toUser int64) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback() // Commit 後の Rollback は無害(ErrTxDone を返すだけ)
if _, err := tx.ExecContext(ctx,
`UPDATE todos SET user_id = $1 WHERE user_id = $2`, toUser, fromUser); err != nil {
return fmt.Errorf("move todos: %w", err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO audit_log (action, detail) VALUES ('move', $1)`,
fmt.Sprintf("%d -> %d", fromUser, toUser)); err != nil {
return fmt.Errorf("audit: %w", err)
}
return tx.Commit()
}
defer tx.Rollback() を最初に置くことで、途中の return err や panic でも確実にロールバックされます。
トランザクションヘルパー
毎回書くのは冗長なので、関数を受け取るヘルパーを用意します。
func WithTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) (err error) {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() {
if p := recover(); p != nil {
tx.Rollback()
panic(p)
}
if err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
err = errors.Join(err, rbErr)
}
return
}
err = tx.Commit()
}()
return fn(tx)
}
// 使い方
err := WithTx(ctx, db, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, `...`); err != nil {
return err
}
return nil
})
リポジトリをトランザクション対応にする
*sql.DB と *sql.Tx は共通のメソッドを持つので、インターフェースで抽象化できます。
// DBTX は *sql.DB と *sql.Tx の両方が満たす
type DBTX interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...any) *sql.Row
}
type TodoRepository struct {
db DBTX
}
// トランザクション内で使うリポジトリを返す
func (r *TodoRepository) WithTx(tx *sql.Tx) *TodoRepository {
return &TodoRepository{db: tx}
}
// サービス層でまとめる
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
todoRepo := s.todos.WithTx(tx)
userRepo := s.users.WithTx(tx)
// 両方同じトランザクション内で操作
return nil
})
この DBTX パターンは sqlc が生成するコードでも採用されています。
分離レベル
tx, err := db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable,
ReadOnly: false,
})
通常はデフォルト(PostgreSQL は READ COMMITTED)で十分です。残高計算のような厳密さが必要な場面で SERIALIZABLE や SELECT ... FOR UPDATE を検討します。
SELECT balance FROM accounts WHERE id = $1 FOR UPDATE; -- 行ロック
9. リポジトリをインターフェースにして HTTP と繋ぐ
ハンドラ(第3回)は 具象の *TodoRepository ではなく、必要なメソッドだけを持つインターフェース に依存させます。
// internal/handler/todo.go
package handler
type TodoRepo interface {
Create(ctx context.Context, p repository.CreateTodoParams) (repository.Todo, error)
Get(ctx context.Context, userID, id int64) (repository.Todo, error)
List(ctx context.Context, p repository.ListTodosParams) ([]repository.Todo, error)
Update(ctx context.Context, p repository.UpdateTodoParams) (repository.Todo, error)
Delete(ctx context.Context, userID, id int64) error
}
type TodoHandler struct {
repo TodoRepo
}
func NewTodoHandler(repo TodoRepo) *TodoHandler {
return &TodoHandler{repo: repo}
}
func (h *TodoHandler) create(w http.ResponseWriter, r *http.Request) {
userID := auth.UserIDFrom(r.Context()) // 第5回で実装
var in createTodoRequest
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if in.Title == "" {
writeError(w, http.StatusUnprocessableEntity, "title is required")
return
}
t, err := h.repo.Create(r.Context(), repository.CreateTodoParams{
UserID: userID,
Title: in.Title,
Description: in.Description,
})
if err != nil {
slog.ErrorContext(r.Context(), "create todo", "err", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusCreated, toResponse(t))
}
レスポンス用の型を分ける
DB の型(repository.Todo)をそのまま JSON にしない方が、スキーマ変更と API 仕様を独立させられます。
type todoResponse struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description *string `json:"description"`
Done bool `json:"done"`
DueDate *string `json:"due_date"`
CreatedAt time.Time `json:"created_at"`
}
func toResponse(t repository.Todo) todoResponse {
res := todoResponse{
ID: t.ID, Title: t.Title, Description: t.Description,
Done: t.Done, CreatedAt: t.CreatedAt,
}
if t.DueDate != nil {
s := t.DueDate.Format("2006-01-02")
res.DueDate = &s
}
return res
}
モックで HTTP 層をテストする
インターフェースにした恩恵で、DB なしでハンドラをテストできます。
type fakeTodoRepo struct {
TodoRepo // 埋め込みで未実装メソッドを補う(呼ぶと nil panic するので注意)
createFn func(ctx context.Context, p repository.CreateTodoParams) (repository.Todo, error)
}
func (f *fakeTodoRepo) Create(ctx context.Context, p repository.CreateTodoParams) (repository.Todo, error) {
return f.createFn(ctx, p)
}
func TestCreateTodo_RepoError(t *testing.T) {
repo := &fakeTodoRepo{
createFn: func(_ context.Context, _ repository.CreateTodoParams) (repository.Todo, error) {
return repository.Todo{}, errors.New("db down")
},
}
h := NewTodoHandler(repo)
req := httptest.NewRequest("POST", "/todos", strings.NewReader(`{"title":"x"}`))
rec := httptest.NewRecorder()
h.create(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d", rec.Code)
}
}
10. sqlc:SQL からコード生成
Scan の手書きは列が増えると辛くなります。sqlc は SQL を書くだけで型安全な Go コードを生成します。Go 界隈で最も勢いのある選択肢です。
brew install sqlc
設定
sqlc.yaml
version: "2"
sql:
- engine: "postgresql"
queries: "db/queries"
schema: "db/migrations"
gen:
go:
package: "sqlc"
out: "internal/db/sqlc"
sql_package: "pgx/v5"
emit_json_tags: true
emit_pointers_for_null_types: true
emit_interface: true
overrides:
- db_type: "timestamptz"
go_type: "time.Time"
クエリを書く
db/queries/todos.sql
-- name: CreateTodo :one
INSERT INTO todos (user_id, title, description, due_date)
VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: GetTodo :one
SELECT * FROM todos
WHERE id = $1 AND user_id = $2;
-- name: ListTodos :many
SELECT * FROM todos
WHERE user_id = $1
AND (sqlc.narg('done')::boolean IS NULL OR done = sqlc.narg('done'))
ORDER BY created_at DESC, id DESC
LIMIT $2 OFFSET $3;
-- name: CountTodos :one
SELECT COUNT(*) FROM todos WHERE user_id = $1;
-- name: UpdateTodo :one
UPDATE todos
SET title = $3, description = $4, done = $5, due_date = $6, updated_at = NOW()
WHERE id = $1 AND user_id = $2
RETURNING *;
-- name: DeleteTodo :execrows
DELETE FROM todos WHERE id = $1 AND user_id = $2;
db/queries/users.sql
-- name: CreateUser :one
INSERT INTO users (email, password_hash)
VALUES ($1, $2)
RETURNING *;
-- name: GetUserByEmail :one
SELECT * FROM users WHERE email = $1;
コメントの :one / :many / :exec / :execrows が戻り値の形を決めます。
sqlc generate
生成されるコード
// internal/db/sqlc/todos.sql.go(抜粋)
type CreateTodoParams struct {
UserID int64 `json:"user_id"`
Title string `json:"title"`
Description *string `json:"description"`
DueDate pgtype.Date `json:"due_date"`
}
func (q *Queries) CreateTodo(ctx context.Context, arg CreateTodoParams) (Todo, error) {
row := q.db.QueryRow(ctx, createTodo, arg.UserID, arg.Title, arg.Description, arg.DueDate)
var i Todo
err := row.Scan(&i.ID, &i.UserID, &i.Title, ...)
return i, err
}
emit_interface: true で Querier インターフェースも生成され、モックに使えます。
使い方
import "github.com/jackc/pgx/v5/pgxpool"
pool, err := pgxpool.New(ctx, dsn)
queries := sqlc.New(pool)
todo, err := queries.CreateTodo(ctx, sqlc.CreateTodoParams{
UserID: 1, Title: "Go を学ぶ",
})
// トランザクション
tx, err := pool.Begin(ctx)
defer tx.Rollback(ctx)
q := queries.WithTx(tx)
q.CreateTodo(ctx, ...)
q.CreateTodo(ctx, ...)
tx.Commit(ctx)
sqlc の長所と短所
長所
- SQL がそのまま見える。パフォーマンス調整がしやすい
- コンパイル時に SQL の構文・列名・型が検証される
- 生成コードは読める。ブラックボックスがない
短所
- 動的な条件(フィルタの組み合わせが多い)は
nargやCASEで工夫が必要 - JOIN 結果の構造体は自動生成されるが、ネストした構造にはならない
- スキーマ変更後は
sqlc generateを忘れないこと(CI で差分チェックすると安全)
11. pgx をネイティブで使う
database/sql を経由せず pgx を直接使うと、PostgreSQL 固有の機能(配列、JSONB、COPY、LISTEN/NOTIFY)や高速なバイナリプロトコルが使えます。
import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
pool, err := pgxpool.New(ctx, dsn)
defer pool.Close()
// 複数行を構造体スライスに一発で
rows, _ := pool.Query(ctx, `SELECT id, title FROM todos WHERE user_id = $1`, userID)
todos, err := pgx.CollectRows(rows, pgx.RowToStructByName[Todo])
// 1 行
todo, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[Todo])
// 大量 INSERT は COPY が速い
_, err = pool.CopyFrom(ctx, pgx.Identifier{"todos"},
[]string{"user_id", "title"},
pgx.CopyFromRows(rowsData),
)
pgx.RowToStructByName は列名と構造体フィールド(db タグ)を自動でマッピングします。
選択の指針:DB が PostgreSQL 固定なら pgx ネイティブ + sqlc。将来 MySQL も視野に入るなら database/sql。
12. ORM(GORM)との比較
import "gorm.io/gorm"
type Todo struct {
gorm.Model
UserID uint
Title string
Done bool
}
db.Create(&Todo{UserID: 1, Title: "x"})
db.Where("user_id = ? AND done = ?", 1, false).Find(&todos)
db.Model(&todo).Update("done", true)
db.Preload("User").Find(&todos) // JOIN の代わり
GORM を選ぶ場面:プロトタイプを最速で作りたい、Rails/Django 出身でモデル中心の書き方に慣れている、CRUD が単純。
sqlc / 素の SQL を選ぶ場面:クエリのパフォーマンスを制御したい、複雑な JOIN や集計が多い、チームが SQL に強い。
Go コミュニティの傾向としては 「SQL は隠さない」 が主流です。学習の順序としては、素の database/sql → sqlc を経て、必要なら GORM を試す形をおすすめします。
13. DB を含めたテスト
リポジトリのテストは 本物の PostgreSQL で行うのが最も信頼できます。モックでは SQL のミスを検出できません。
方針 A:Docker Compose で立てた DB を使う
環境変数で DSN を渡し、なければスキップします。
// internal/repository/testing.go
package repository
import (
"context"
"database/sql"
"os"
"testing"
)
func testDB(t *testing.T) *sql.DB {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
db, err := Open(context.Background(), dsn)
if err != nil {
t.Fatalf("open test db: %v", err)
}
t.Cleanup(func() { db.Close() })
// テスト間の独立性:テーブルを空にする
if _, err := db.Exec(`TRUNCATE todos, users RESTART IDENTITY CASCADE`); err != nil {
t.Fatalf("truncate: %v", err)
}
return db
}
// internal/repository/todo_test.go
func TestTodoRepository_CreateAndGet(t *testing.T) {
db := testDB(t)
users := NewUserRepository(db)
todos := NewTodoRepository(db)
ctx := context.Background()
u, err := users.Create(ctx, "a@example.com", "hash")
if err != nil {
t.Fatal(err)
}
created, err := todos.Create(ctx, CreateTodoParams{UserID: u.ID, Title: "test"})
if err != nil {
t.Fatal(err)
}
got, err := todos.Get(ctx, u.ID, created.ID)
if err != nil {
t.Fatal(err)
}
if got.Title != "test" || got.Done {
t.Errorf("got %+v", got)
}
// 他人の ID では見えない
_, err = todos.Get(ctx, u.ID+1, created.ID)
if !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
func TestUserRepository_DuplicateEmail(t *testing.T) {
db := testDB(t)
users := NewUserRepository(db)
ctx := context.Background()
users.Create(ctx, "dup@example.com", "h")
_, err := users.Create(ctx, "dup@example.com", "h")
if !errors.Is(err, ErrEmailTaken) {
t.Errorf("expected ErrEmailTaken, got %v", err)
}
}
TEST_DATABASE_URL="postgres://app:secret@localhost:5432/todo_test?sslmode=disable" go test ./internal/repository/
方針 B:testcontainers でテストごとにコンテナを起動
CI で DB の準備が不要になります。起動に数秒かかるので TestMain で 1 回だけ立てます。
go get github.com/testcontainers/testcontainers-go/modules/postgres
var testDSN string
func TestMain(m *testing.M) {
ctx := context.Background()
pg, err := postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("test"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").WithOccurrence(2)),
)
if err != nil {
log.Fatal(err)
}
testDSN, _ = pg.ConnectionString(ctx, "sslmode=disable")
runMigrations(testDSN)
code := m.Run()
pg.Terminate(ctx)
os.Exit(code)
}
方針 C:トランザクションで包んで Rollback
各テストを BEGIN で始めて終わりに ROLLBACK すれば TRUNCATE 不要で速くなります。リポジトリが DBTX インターフェースに依存していれば *sql.Tx を渡すだけです。
func testTx(t *testing.T, db *sql.DB) *sql.Tx {
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { tx.Rollback() })
return tx
}
repo := &TodoRepository{db: testTx(t, db)}
テストで確認すべきこと
- CRUD の往復
ErrNotFoundが返るケース- 他ユーザーのデータにアクセスできないこと
- UNIQUE 制約や CHECK 制約の違反が適切なエラーになること
- ページネーションの境界(0 件、limit ちょうど、offset 超過)
- トランザクションが途中で失敗したときロールバックされること
14. パフォーマンスの基本
EXPLAIN ANALYZE を見る癖
EXPLAIN ANALYZE
SELECT * FROM todos WHERE user_id = 1 AND done = false ORDER BY created_at DESC LIMIT 20;
Seq Scan が出たらインデックスを検討します。WHERE と ORDER BY に使う列の組み合わせで複合インデックスを作ります。
N+1 問題
// ✗ ユーザーごとに todos を取る = N+1 回のクエリ
for _, u := range users {
todos, _ := repo.ListByUser(ctx, u.ID)
}
// ✓ IN 句で一括取得してから Go 側でグルーピング
rows, _ := db.QueryContext(ctx,
`SELECT user_id, id, title FROM todos WHERE user_id = ANY($1)`, userIDs)
PostgreSQL では = ANY($1) にスライスを渡せます(pgx が配列に変換)。
ページネーション
OFFSET は大きくなると遅くなります。ID や作成日時で カーソル方式 にすると一定速度です。
SELECT * FROM todos
WHERE user_id = $1 AND (created_at, id) < ($2, $3)
ORDER BY created_at DESC, id DESC
LIMIT 20;
プリペアドステートメント
同じ SQL を大量に実行するなら PrepareContext でパースコストを削減できます。pgx は自動でキャッシュするので、通常は意識不要です。
コネクション枯渇の検出
stats := db.Stats()
slog.Info("db pool", "open", stats.OpenConnections, "in_use", stats.InUse, "wait", stats.WaitCount)
WaitCount が増え続けていたら MaxOpenConns が足りないか、接続を返し忘れています(rows.Close() 漏れが典型)。
15. よくある落とし穴
rows.Close() 忘れ
接続がプールに戻らず枯渇します。defer rows.Close() を Query 直後に書きます。
rows.Err() を見ない
ネットワーク切断などが検出できません。
SELECT * と Scan の列数不一致
カラム追加で全クエリが壊れます。列名を明示するか sqlc を使います。
sql.ErrNoRows を 500 にしている
「見つからない」は正常なケース。ErrNotFound に変換して 404 にします。
リクエストごとに sql.Open*sql.DB はプールです。起動時に 1 つ作って共有します。
context を渡さない Exec / Query
リクエスト中断時にクエリが止まりません。常に 〜Context 版を使います。
defer tx.Rollback() を書かない
エラー時にトランザクションが開いたまま接続を占有します。
時刻を TIMESTAMP(タイムゾーンなし)で保存
サーバーと DB のタイムゾーンが違うとずれます。TIMESTAMPTZ を使い、Go 側は UTC で扱います。
マイグレーションファイルを後から編集
適用済みの環境と差分が出ます。新しいマイグレーションを追加します。
user_id を WHERE に含めない
URL の ID を変えるだけで他人のデータが見える IDOR 脆弱性になります。
16. 演習問題
基礎
- Update の部分更新:
titleだけ、doneだけを更新できるPatchメソッドを実装する。COALESCE($2, title)を使って NULL なら既存値を維持する - タグ機能:
tagsテーブルとtodo_tags中間テーブルをマイグレーションで追加し、トランザクション内で Todo と タグを同時に登録するCreateWithTagsを実装する - 検索:
title ILIKE '%' || $1 || '%'による部分一致検索をListに追加する。SQL インジェクションが起きないことを確認するテストを書く
中級
- sqlc 移行:手書きの
TodoRepositoryを sqlc 生成コードに置き換える。既存のテストが通ることを確認する - カーソルページネーション:
(created_at, id)を使ったカーソル方式を実装し、next_cursorを返す。1000 件入れて全ページ走査するテストを書く - 楽観ロック:
versionカラムを追加し、UPDATE ... WHERE id = $1 AND version = $2で更新競合を検出する。競合時はErrConflictを返して 409 にする
応用
- testcontainers 化:
TestMainで PostgreSQL コンテナを起動し、マイグレーションを適用してからテストを走らせる。go test ./...一発で DB テストが動く状態にする - 統計クエリ:ユーザーごとの「未完了数・完了数・期限超過数」を 1 つの SQL で返す
Statsメソッドを書く(COUNT(*) FILTER (WHERE ...)を使う) - バルクインサート:1 万件の Todo を
pgx.CopyFromと 1 件ずつ INSERT の両方で入れ、ベンチマークで速度差を計測する - 監査ログ:全ての書き込み操作を
audit_logsテーブルに記録する。リポジトリをラップするAuditingTodoRepositoryを作り、同一トランザクションで記録されることをテストする
次回予告
第5回では、この API を 本番で動かすための周辺技術──Docker、設定管理、構造化ログ、JWT 認証、そして goroutine / channel / context を使った並行処理──を扱います。
Analyzegear