GORM CreateInBatchesの一括挿入とトランザクション範囲|v1.31.2の基本操作
GORMで数千件をまとめて登録するとき、スライスをCreateに渡す書き方とCreateInBatchesのどちらを選ぶかで、発行されるSQLも失敗時に巻き戻る範囲も変わります。公式ドキュメントは書き方は示しますが、バッチ全体を包むトランザクションがどの条件で張られるかまでは書いていません。この記事では最新版 v1.31.2 の実装を読んで確かめた挙動を軸に、作成・取得・削除・トランザクション・マイグレーションの基本を動くコードで整理します。
まとめ
- スライスを
db.Create(&users)に渡すと1文のINSERTで全件挿入され、主キーが構造体へ書き戻されます。件数が読めない場合はCreateInBatches(users, 100)で分割します。 - GORMは既定で書き込みをトランザクションに入れます。
CreateInBatchesが追加で張るのは全バッチをまたぐ外側のトランザクションで、これは2バッチ以上に分割されたときだけです。 - そのため分割が起きると、最後のバッチで失敗した場合に先行バッチの挿入もまとめて巻き戻ります。1バッチずつ確定させたい取り込み処理では逆効果になります。
- 重複キーは
clause.OnConflictで制御します。無視するならDoNothing、上書きするならUpdateAllです。 - 条件を付けない
db.Delete(&User{})は実行されずgorm.ErrMissingWhereClauseを返します。全件削除には意図を明示した書き方が必要です。
以下、それぞれの根拠と具体的な書き方を見ていきます。
GORMの導入と動作要件(v1.31.2時点)
GORMはGo言語向けのORMライブラリで、構造体とテーブルを対応づけてSQLの記述を減らします。最新版はv1.31.2(2026年6月公開)で、go.modのgoディレクティブは1.18です。ORMという仕組み自体の位置づけはORM(Object-Relational Mapping)とは|仕組み・メリット・デメリットと読み方をやさしく解説で整理しています。
本体とドライバは別モジュールなので、使うデータベースのドライバを個別に取得します。
go get -u gorm.io/gorm
go get -u gorm.io/driver/mysql
接続はgorm.Openにドライバと設定を渡すだけです。以降のコードはこのモデル定義を前提とし、import文を省略した断片で示します。
type User struct {
ID uint `gorm:"primarykey"`
Name string
Age int
Point int
Orders []Order
CreatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
type Order struct {
ID uint
UserID uint
Total int
Status string
}
func openDB(dsn string) (*gorm.DB, error) {
return gorm.Open(mysql.Open(dsn), &gorm.Config{})
}
Userにgorm.DeletedAtを持たせているため、この記事の削除操作はすべてソフトデリートとして動きます。物理削除との違いは後述します。
レコード作成|Createの一括挿入とCreateInBatchesの分割
スライスを渡すCreateと主キーの書き戻し
Createにスライスを渡すと、GORMは1文のINSERTで全件を挿入し、採番された主キーを元の構造体へ書き戻します。BeforeCreateなどのフックも各レコードに対して呼ばれます。
users := []User{
{Name: "sato", Age: 32},
{Name: "suzuki", Age: 28},
{Name: "takahashi", Age: 45},
}
if err := db.Create(&users).Error; err != nil {
return err
}
fmt.Println(users[0].ID) // 採番された主キーが入る
この書き方は件数が数十件までなら扱いやすい一方、1文のINSERTがそのまま巨大化します。MySQLのmax_allowed_packetやPostgreSQLのパラメータ数上限に当たると、実行時までエラーが分かりません。件数が入力データ依存で読めないなら、次のバッチ分割に切り替えます。
CreateInBatchesでのバッチ分割とCreateBatchSizeの指定
CreateInBatchesは第2引数のbatchSizeごとにスライスを切り出し、複数回のINSERTに分けます。10,000件をbatchSize=100で渡せば、100件ずつ100回のINSERTになります。
// users には10,000件が入っている
if err := db.CreateInBatches(users, 100).Error; err != nil {
return err
}
呼び出しごとに数値を書くのが煩雑なら、接続時またはセッション単位で既定のバッチサイズを決められます。CreateBatchSizeが設定されていると、v1.31.2のCreateは先頭でCreateBatchSize > 0を判定し、そのままCreateInBatchesへ処理を渡す実装です。通常のCreate呼び出しも分割挿入に変わる点に注意してください。
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
CreateBatchSize: 1000,
})
// セッション単位で上書きする場合
tx := db.Session(&gorm.Session{CreateBatchSize: 1000})
ここで引っかかりやすいのが、Sessionで反映されるのは正の値だけという実装です(gorm.goはconfig.CreateBatchSize > 0のときだけ上書きします)。0を渡してもConfig側の設定は解除されません。特定の処理だけ分割したいなら、Configには設定せずSession側でのみ指定してください。
重複キーを扱うOnConflictの3パターン
一括挿入では、既存レコードと主キーやユニークキーが衝突する場面がついて回ります。clause.OnConflictで衝突時の動作を指定すると、INSERT前に存在確認のSELECTを回す必要がなくなります。
// 衝突した行は無視して残りを挿入する
db.Clauses(clause.OnConflict{DoNothing: true}).Create(&users)
// idが衝突したらnameとageだけ更新する
db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.AssignmentColumns([]string{"name", "age"}),
}).Create(&users)
// 衝突時に全カラムを新しい値で上書きする
db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&users)
取り込み処理のように同じデータを何度流しても結果が変わらないようにしたい場合はUpdateAll、初回だけ登録したい場合はDoNothingを選びます。clauseパッケージはgorm.io/gorm/clauseを別途importします。
CreateInBatchesのトランザクション範囲とバッチサイズ設計
外側トランザクションが張られる条件と巻き戻る範囲
まず前提として、GORMは作成・更新・削除の各実行を既定でトランザクションに入れます。作成用のコールバック列にgorm:begin_transactionとgorm:commit_or_rollback_transactionが登録されており、SkipDefaultTransactionが無効なかぎり毎回Beginが走るためです。
そのうえでCreateInBatchesが追加するのが、全バッチをまたぐ外側のトランザクションです。v1.31.2のfinisher_api.goは、SkipDefaultTransactionが有効なとき、または渡した件数がbatchSize以下のときは外側を張らずに実行し、それ以外、つまり2バッチ以上に分割されたときだけ全バッチを1つのトランザクションで包みます。
ここから導かれる注意点は、分割が起きたときに巻き戻る範囲です。10,000件をbatchSize=1,000で流して10バッチ目で制約違反が出た場合、先行する9,000件の挿入もまとめてロールバックされます。「入った分だけ残し、失敗した行は後で再投入する」運用を想定していると、期待と逆の結果になります。1バッチずつ確定させたいなら、CreateInBatchesに丸投げせず、自前でスライスを区切って1回ずつCreateを呼んでください。
batchSizeを決めるパラメータ数上限とロック保持時間
batchSizeの上限側を決めるのは、1回のINSERTで送るパラメータ数(batchSize × カラム数)です。PostgreSQLはBindメッセージのパラメータ数をInt16で持つため上限があり、pgxドライバは超過時に「extended protocol limited to 65535 parameters」を返します。MySQLで先に当たるのは1文のサイズ、つまりmax_allowed_packetの方です。
| 条件 | batchSize | 効いてくる制約 |
|---|---|---|
| カラム数10前後 | 1000以下 | パラメータ数上限 |
| カラム数30以上・TEXT列あり | 50〜200 | 1文のサイズ |
| 稼働中DBへの投入 | 大きめ | 外側トランザクションの保持時間 |
| 件数が常にbatchSize以下 | 見直し対象 | 分割も外側トランザクションも起きない |
3行目が直感と逆になる点に注意してください。batchSizeを小さくするとバッチ数が増え、それらを覆う外側トランザクションの生存時間は延びます。とはいえ大きくしすぎれば単一の巨大なINSERTがロックを長く持つため、上限側の制約に当たる手前が実質の天井です。ロック保持時間そのものを短くしたいなら、batchSizeの調整ではなく外側トランザクションを作らない形、つまり自前で区切って1バッチずつコミットする方に倒します。
レコード取得|条件指定とPreloadによるN+1回避
First・Find・Whereによる基本のクエリ
単一レコードはFirst、複数レコードはFindで取得します。条件はプレースホルダ付きのWhereで渡し、値を文字列連結で埋め込まないようにします。
var user User
db.First(&user, 10)
// SELECT * FROM users WHERE users.id = 10 AND users.deleted_at IS NULL ORDER BY users.id LIMIT 1
var users []User
db.Where("age >= ?", 20).
Order("age desc").
Limit(50).
Find(&users)
Firstは該当レコードが無いとgorm.ErrRecordNotFoundを返しますが、Findは0件でもエラーになりません。件数を確認するならRowsAffectedを見ます。モデルがgorm.DeletedAtを持つ場合、検索条件には削除済みを除く述語が自動で付きます。
Preloadでの関連レコード取得
関連レコードをループの中で個別に取得すると、親レコードの件数だけSELECTが増えます。Preloadを使うと、関連先はIN句を使った1回のSELECTでまとめて読み込まれます。
var users []User
db.Preload("Orders").Find(&users)
// users用に1回、orders用にIN句で1回、計2回のSELECTになる
// 関連先を絞り込む場合
db.Preload("Orders", "status = ?", "paid").Find(&users)
1回のSELECTで結合まで済ませたい場合はJoinsを使いますが、一対多で結合すると親行が重複するため、件数を数える処理では扱いに注意が必要です。
レコード削除|ソフトデリートと全件削除ブロック
主キー指定と条件指定の削除
削除はDeleteに対象のモデルと条件を渡します。主キーはインライン条件で指定でき、スライスを渡せばIN句になります。
db.Delete(&User{}, 10)
// UPDATE users SET deleted_at="2026-08-03 10:23:00"
// WHERE users.id = 10 AND users.deleted_at IS NULL
db.Delete(&users, []int{1, 2, 3})
// WHERE users.id IN (1,2,3) AND users.deleted_at IS NULL
db.Where("age < ?", 18).Delete(&User{})
発行されるのがUPDATE文なのは、前掲のUserがgorm.DeletedAtを持つためです。このフィールドが無いモデルでは同じコードがDELETE文になります。
gorm.DeletedAtによるソフトデリートとUnscoped
モデルにgorm.DeletedAt型のフィールドがあると、GORMは物理削除ではなく削除時刻の書き込みに切り替えます。以降の検索からは削除済みレコードが自動的に外れます。除外を解除したい場合も物理削除したい場合も、使うのはUnscopedです。
// 削除済みも含めて取得する
db.Unscoped().Where("age = ?", 20).Find(&users)
// ソフトデリートを無視して物理削除する
db.Unscoped().Delete(&order)
ソフトデリートは元に戻せる代わりに、ユニーク制約が効いたままになる点が落とし穴です。削除済みの同名レコードが残っていると、同じ値での再登録が制約違反になります。再登録が前提のマスタデータでは、削除フラグを含む複合ユニーク制約にするか、物理削除に寄せる判断が必要です。
ErrMissingWhereClauseが返る条件と回避策
条件を1つも指定しない削除は、GORMが実行前に止めてgorm.ErrMissingWhereClauseを返します。WHERE句の付け忘れによる全件削除を防ぐ仕組みです。
err := db.Delete(&User{}).Error
fmt.Println(errors.Is(err, gorm.ErrMissingWhereClause)) // true
// 全件削除を意図的に行う場合
db.Where("1 = 1").Delete(&User{})
db.Exec("DELETE FROM users")
db.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&User{})
アプリケーションコードでこの3つを書く場面はほとんどありません。エラーが出たら回避策を足すのではなく、想定した条件が抜け落ちていないかを先に疑ってください。
トランザクション|Transactionクロージャ・SavePoint・既定動作の無効化
Transactionクロージャによる自動コミットとロールバック
db.Transactionにクロージャを渡すと、nilを返した時点でコミット、errorを返した時点でロールバックされます。BeginとCommitを手書きするより、returnの漏れによるトランザクション放置が起きにくくなります。
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&Order{UserID: 1, Total: 4800, Status: "paid"}).Error; err != nil {
return err
}
if err := tx.Model(&User{}).
Where("id = ?", 1).
Update("point", gorm.Expr("point - ?", 480)).Error; err != nil {
return err
}
return nil
})
クロージャの中では引数のtxを使います。外側のdbを呼ぶとトランザクション外の別コネクションで実行され、ロールバックしても取り消せません。
SavePointとRollbackToによる部分ロールバック
トランザクションの一部だけを取り消したい場合はセーブポイントを打ちます。任意処理が失敗しても本処理は続行したい、といった場面で使います。
tx := db.Begin()
tx.Create(&user1)
tx.SavePoint("sp1")
tx.Create(&user2)
tx.RollbackTo("sp1") // user2の挿入だけ取り消す
tx.Commit() // RollbackTo後もCommitは必要。user1が確定する
この仕組みはネストしたTransactionでも使われます。v1.31.2の実装では、トランザクション中にさらにTransactionを呼ぶと、DisableNestedTransactionが無効なかぎり内側でセーブポイントを打ち、内側がエラーを返したときとpanicしたときにそこへ巻き戻します。ここで誤解しやすいのがDisableNestedTransaction: trueの効果です。この設定はセーブポイントを打たなくするだけで、内側の失敗が外側のロールバックを引き起こすようになるわけではありません。内側の失敗で外側もろとも巻き戻したいなら、内側が返したerrorを外側のクロージャからそのままreturnしてください。
SkipDefaultTransactionを外す判断基準
公式ドキュメントは、既定トランザクションを無効化すると「about 30%+ performance improvement」が得られると記載しています。
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
SkipDefaultTransaction: true,
})
ただし、この設定は接続全体に効きます。1件のINSERTしか投げないログ収集のような用途では有効ですが、業務データを扱うアプリケーションで全体設定として外すのは避けてください。前述のCreateInBatchesの外側トランザクションも張られなくなり、失敗時に中途半端な件数が残ります。速度が必要な箇所が特定できているなら、全体設定ではなくその処理だけdb.Session(&gorm.Session{SkipDefaultTransaction: true})で切り替える方が影響範囲を絞れます。
マイグレーション|AutoMigrateの適用範囲と手動運用への切り替え基準
AutoMigrateはモデル定義からテーブル・カラム・インデックス・外部キー・制約を作成し、サイズや精度の変更、NOT NULLからNULL許可への変更にも追随します。一方で、公式ドキュメントが明記しているとおり、データ保護のため使われなくなったカラムは削除しません。
db.AutoMigrate(&User{}, &Order{})
つまりカラム名の変更は「新カラムの追加」として扱われ、旧カラムが残ったまま本番のスキーマが積み上がります。開発初期やテスト用スキーマの再作成には向きますが、リネームやカラム削除、データ移行を伴う変更が出てきた段階で、バージョン管理されたマイグレーションファイルを適用する方式へ切り替える判断が必要です。切り替えの目安は、スキーマ変更をレビュー対象にしたくなったときです。
ジェネリクスAPI(v1.30.0以降)と従来APIの使い分け
v1.30.0(2025年5月25日リリース)でジェネリクスAPIが追加され、gorm.G[T]を起点に型付きで書けるようになりました。従来APIとの実務上の違いは、結果を&usersのようなポインタ渡しではなく戻り値で受け取ること、context.Contextが引数として必須になったこと、そして.Errorを経由せずerrorを直接扱うことです。
ctx := context.Background()
// 第2引数はスライスのポインタ、戻り値はerror
if err := gorm.G[User](db).CreateInBatches(ctx, &users, 100); err != nil {
return err
}
got, err := gorm.G[User](db).Where("age >= ?", 20).Find(ctx)
if err != nil {
return err
}
fmt.Println(len(got))
rows, err := gorm.G[User](db).Where("age < ?", 18).Delete(ctx)
if err != nil {
return err
}
fmt.Println(rows)
Findは([]T, error)、Deleteは(rowsAffected int, error)を返します。contextを引数で渡す設計になったことで、タイムアウトやキャンセルがクエリ単位で扱いやすくなりました。contextの受け渡し自体はGo言語のcontext入門|BackgroundとTODOの使い分け、キャンセルとタイムアウトの実装で整理しています。
新規に書くコードはgorm.G[T]側で始める価値があります。型が合わないコードがコンパイル時に落ちるためです。一方、既存コードの一括移行は勧めません。ジェネリクスAPIでは従来APIにあった一部のメソッドが使えず、書き換えで挙動が変わる箇所があるからです。移行時に消えたメソッドと代替の書き方はGORM v1.30.0のジェネリクスAPI変更点|削除されたFirstOrCreate・Saveと移行のポイントで扱っています。
よくある質問
Createにスライスを渡す方法とCreateInBatchesはどう使い分けますか。
件数が固定で数十件までならCreateにスライスを渡す方法で十分です。1文のINSERTで済むため往復も1回になります。件数が入力データ次第で変わる、または数千件を超える可能性があるならCreateInBatchesを選びます。1文が大きくなりすぎてMySQLのmax_allowed_packetやPostgreSQLのパラメータ数上限に当たる事故を、実行時ではなく設計時に避けられるためです。既定値としてgorm.ConfigのCreateBatchSizeを設定しておけば、v1.31.2のCreateは内部でCreateInBatchesへ処理を渡すので、呼び出し側を書き換えずに分割できます。
CreateInBatchesのbatchSizeはどのくらいの値にすべきですか。
上限側は、batchSize×カラム数が1文あたりのパラメータ数上限に収まる範囲で決めます。PostgreSQLはBindメッセージのパラメータ数をInt16で持ち、pgxドライバは65,535を超えるとエラーを返します。カラム数10前後なら1,000以下、TEXT列を含みカラム数が30を超えるなら50〜200程度が出発点です。下限側は、小さくするほどバッチ数が増え、それらを覆う外側トランザクションの生存時間が延びる点で制約されます。ロック保持時間を短くしたい場合は、batchSizeを下げるのではなく自前で1バッチずつコミットする形に変えてください。
db.Delete(&User{})がエラーになるのはなぜですか。
条件を1つも指定しない削除をGORMが実行前にブロックし、gorm.ErrMissingWhereClauseを返すためです。WHERE句の付け忘れで全件が消える事故を防ぐ仕様です。意図的な全件削除ならdb.Where("1 = 1").Delete(&User{})、db.Exec("DELETE FROM users")、db.Session(&gorm.Session{AllowGlobalUpdate: true})のいずれかで明示します。ただし業務コードで必要になる場面はまれなので、まず条件変数が空になっていないかを確認してください。
ソフトデリートしたレコードを取得したり完全に削除したりするにはどうしますか。
Unscopedを挟みます。db.Unscoped().Where("age = ?", 20).Find(&users)で削除済みを含めて取得でき、db.Unscoped().Delete(&order)で物理削除になります。ソフトデリートはモデルにgorm.DeletedAt型のフィールドがあると自動的に有効になるため、物理削除のつもりでDeleteを呼んでいた場合はUPDATE文が発行されている点に注意が必要です。ユニーク制約は削除済み行にも効いたままなので、同じ値の再登録が必要なテーブルでは制約設計を見直してください。
GORMの読み方に決まりはありますか。
gorm.ioの公式サイトとドキュメントを確認した範囲では、読み方や名称の由来についての記載は見当たりません(2026年8月時点)。サイトのタグラインは「The fantastic ORM library for Golang, aims to be developer friendly.」で、Go向けのORMライブラリという位置づけだけが示されています。ORMという用語自体の読み方と仕組みはORM(Object-Relational Mapping)とはで解説しています。