// ユーザー一覧と投稿を取得する、よくあるコード
var users []User
db.Find(&users)
for _, u := range users {
var posts []Post
db.Where("user_id = ?", u.ID).Find(&posts)
fmt.Printf("%s: %d件\n", u.Name, len(posts))
}
一見すると普通に動くこのコード、ユーザーが100人いれば101回のSQLクエリが走る。開発環境のSQLite では一瞬で返ってくるから気づかないけれど、本番のMySQL でレコードが増えたときにレスポンスが10秒を超えて初めて問題に気づく――GORMを使った開発で、たぶん一番多い事故パターンがこれです。
GORMはGoで最も使われているORMライブラリで、CRUD操作をシンプルに書ける反面、暗黙の挙動に頼りすぎると本番で痛い目に遭います。この手の問題は公式ドキュメントにも書いてあるんですが、入門記事だけ読んで使い始めると見落としがちです。
ここでは GORM v2(2.0)+ Go 1.22 + PostgreSQL 16 の環境で、実際に踏みやすい3つの罠とその回避策を整理します。
N+1問題—Preloadを忘れるとクエリ数が爆発する
なぜ起きるのか
GORMのリレーション(HasMany, BelongsToなど)は、デフォルトでは遅延ロードです。関連データは明示的に読み込まない限り取得されません。冒頭のコードのように、ループ内で関連データを個別に取得すると、親レコードN件に対してN+1回のクエリが発行されます。
// モデル定義
type User struct {
gorm.Model
Name string
Posts []Post // HasMany
}
type Post struct {
gorm.Model
Title string
UserID uint
}
NGパターン: ループ内でクエリ
var users []User
db.Find(&users)
// ユーザーごとにSELECTが走る = N+1
for _, u := range users {
var posts []Post
db.Where("user_id = ?", u.ID).Find(&posts)
// ...
}
-- 実行されるSQL(ユーザーが3人の場合)
SELECT * FROM users;
SELECT * FROM posts WHERE user_id = 1;
SELECT * FROM posts WHERE user_id = 2;
SELECT * FROM posts WHERE user_id = 3;
-- → 4クエリ。100人なら101クエリ。
回避策: Preloadで一括取得
var users []User
db.Preload("Posts").Find(&users)
for _, u := range users {
fmt.Printf("%s: %d件\n", u.Name, len(u.Posts))
}
-- 実行されるSQL(何人いても2クエリ固定)
SELECT * FROM users;
SELECT * FROM posts WHERE user_id IN (1, 2, 3, ...);
たった.Preload("Posts")を追加するだけで、クエリ数がN+1から2回固定になります。ネストしたリレーション(投稿に紐づくコメントなど)もPreload("Posts.Comments")で一括取得できます。
Preloadに条件をつける
「公開済みの投稿だけ取得したい」ような場合は、Preloadにクロージャで条件を渡せます。
db.Preload("Posts", func(db *gorm.DB) *gorm.DB {
return db.Where("status = ?", "published").Order("created_at DESC")
}).Find(&users)
SELECT * FROM users;
SELECT * FROM posts WHERE user_id IN (...) AND status = 'published' ORDER BY created_at DESC;
条件付きPreloadはパフォーマンスにも直結するので、不要なデータまで読み込まないよう意識しておくといいです。データベース接続まわりの最適化についてはGo のデータベース接続プーリングで何が変わる?database/sqlの最適化と実装のコツも参考になります。
AutoMigrateの限界—本番運用で裏切られるポイント
AutoMigrateがやること・やらないこと
GORMのAutoMigrateは便利ですが、公式ドキュメントにも明記されている通り万能ではないです。
| 操作 | AutoMigrateの対応 |
|---|---|
| テーブル作成 | 対応 |
| カラム追加 | 対応 |
| インデックス作成 | 対応 |
| カラム型変更 | 対応(一部) |
| カラム削除 | 非対応 |
| カラムリネーム | 非対応 |
| テーブル削除 | 非対応 |
開発中にstructからフィールドを消しても、DBのカラムは残り続けます。これが本番で「使われていないカラムにNOT NULL制約がかかっていてINSERTが失敗する」という事故につながることがあります。
NGパターン: AutoMigrateだけで本番運用
// main.goの初期化で毎回AutoMigrate
func main() {
db, _ := gorm.Open(postgres.Open(dsn), &gorm.Config{})
db.AutoMigrate(&User{}, &Post{}, &Comment{})
// → フィールドを消してもカラムは残る
// → 本番で予期しないスキーマ差分が蓄積していく
}
回避策: マイグレーションツールを併用する
本番環境ではgolang-migrateやgooseといった専用のマイグレーションツールを使い、SQLファイルでスキーマ変更を管理するのが安全です。
-- migrations/000002_drop_legacy_column.up.sql
ALTER TABLE users DROP COLUMN IF EXISTS legacy_field;
-- migrations/000002_drop_legacy_column.down.sql
ALTER TABLE users ADD COLUMN legacy_field VARCHAR(255);
// 環境で使い分ける
func setupDB(db *gorm.DB, env string) {
if env == "development" {
// 開発環境ではAutoMigrateで手軽に
db.AutoMigrate(&User{}, &Post{})
}
// 本番・ステージングはgolang-migrateで管理
// AutoMigrateは使わない
}
$ migrate -path ./migrations -database "postgres://..." up
2/2 migrations applied
AutoMigrateは開発環境限定、本番はSQLマイグレーション。この使い分けを最初から決めておくと、後から移行する手間が省けます。
トランザクション管理—db.Transactionを使わないリスク
GORMのトランザクション2つの書き方
GORMにはトランザクションの書き方が2通りあります。
| 方式 | 書き方 | ロールバック |
|---|---|---|
| 手動管理 | db.Begin() / Commit() / Rollback() | 自分でやる |
| クロージャ方式 | db.Transaction(func(tx) error) | 自動 |
NGパターン: 手動管理でRollback漏れ
tx := db.Begin()
user := User{Name: "tanaka"}
tx.Create(&user)
post := Post{Title: "記事", UserID: user.ID}
if err := tx.Create(&post).Error; err != nil {
tx.Rollback() // ここは書いた
return err
}
// ← この後にpanicが起きたらRollbackされない
tx.Commit()
return nil
回避策: db.Transactionクロージャ方式
err := db.Transaction(func(tx *gorm.DB) error {
user := User{Name: "tanaka"}
if err := tx.Create(&user).Error; err != nil {
return err // 自動Rollback
}
post := Post{Title: "記事", UserID: user.ID}
if err := tx.Create(&post).Error; err != nil {
return err // 自動Rollback
}
return nil // 自動Commit
})
-- errorを返す → 自動でROLLBACK
-- nilを返す → 自動でCOMMIT
-- panicが起きても → 自動でROLLBACK + recover
クロージャ方式なら、関数内でerrorを返すだけでロールバック、nilを返せばコミット。panic時もGORMが内部でrecoverしてロールバックしてくれるので、手動管理より安全です。エラーの伝搬パターンについてはGoのエラーハンドリングで失敗しない方法—wrappingパターンと構造化ログの組み合わせも参考になります。
GORMを安全に使うための設計パターン
リポジトリ層でGORMを閉じ込める
GORMの*gorm.DBをハンドラやサービス層に直接渡すと、どこからでもDBアクセスできてしまい、N+1やトランザクション漏れが散在します。リポジトリパターンでGORMへの依存を1箇所に閉じ込めるのが効果的です。
// repository/user_repo.go
type UserRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) FindWithPosts(id uint) (*User, error) {
var user User
err := r.db.Preload("Posts").First(&user, id).Error
return &user, err
}
func (r *UserRepository) CreateWithPosts(user *User) error {
return r.db.Transaction(func(tx *gorm.DB) error {
return tx.Create(user).Error
})
}
リポジトリ層のメリット:
- Preloadの付け忘れをリポジトリ内で防げる
- トランザクション境界が明確になる
- テスト時にインターフェースでモックに差し替えられる
リポジトリをインターフェース化してDIする方法はGoのインターフェースとDI設計パターン—テスタブルなコードの作り方で詳しく書いています。
DryRunとLoggerでクエリを可視化する
N+1問題の厄介なところは、コードを読んだだけでは気づきにくいことです。開発中にクエリログを有効にしておくと、問題を早期に発見できます。
import "gorm.io/gorm/logger"
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
// DryRunで実際には実行せずSQLだけ確認
stmt := db.Session(&gorm.Session{DryRun: true}).
Preload("Posts").Find(&User{}).Statement
fmt.Println(stmt.SQL.String())
2026/04/16 20:30:00 [info] SELECT * FROM "users"
2026/04/16 20:30:00 [info] SELECT * FROM "posts" WHERE "user_id" IN ($1,$2,$3)
logger.Infoモードにすると全SQLがログに出力されるので、意図しないクエリが走っていないかすぐ確認できます。本番ではlogger.Warnに切り替えて、スロークエリだけ検知するのが現実的です。
まとめ
- N+1問題:
Preloadで一括取得。ループ内での個別クエリは原則禁止 - AutoMigrate: 開発環境限定で使い、本番は
golang-migrateやgooseでSQLマイグレーション - トランザクション:
db.Transactionクロージャ方式で自動Rollbackを保証する - 設計: リポジトリ層でGORMを閉じ込め、Preload漏れやトランザクション管理を集約
- 可視化: 開発中は
logger.Infoでクエリログを出し、N+1を早期検知する

