GORMで数千件を登録するとき、スライスをCreateに渡すかCreateInBatchesで分割するかで、発行されるINSERTの数も、途中で失敗したときに残る件数も変わります。公式ドキュメントは「分割するときはトランザクションを開始する」と書くだけで、どの範囲が巻き戻るかまでは示していません。この記事では最新版のGORM v1.31.2をPostgreSQL 18.6につないで実際に流し、巻き戻りの件数、65535パラメータ上限の境界、ネストしたトランザクションの挙動を出力つきで確かめます。
まとめ
CreateInBatches(&users, 4)に10件を渡すとINSERTは3回に分かれ、RowsAffectedは各バッチの合計(10)になります。- 既定設定(SkipDefaultTransaction=false)で2バッチ以上に分かれると、全バッチが1つのトランザクションに入ります。3バッチ目で一意制約違反が起きると、先に入った8件も残りません(実測で0件)。
- 1バッチずつ確定させたいなら、自前でスライスを区切って
Createを呼びます。同じ条件で8件が残りました。 - PostgreSQL(pgx)は1文あたり65,535パラメータが上限です。9列なら7,281行は通り、7,282行で
extended protocol limited to 65535 parametersになりました。batchSizeは「列数×batchSize」がこの値を下回るように決めます。 OnConflict{UpdateAll: true}は競合ターゲットを省くと主キーしか見ません。メールアドレスなどのユニークキーで取り込むならColumnsを指定します。
検証環境とGORMの導入
GORMはGo言語向けのORMライブラリで、最新版はpkg.go.devで2026年6月22日公開と表示されるv1.31.2です(2026年11月1日確認)。ORMという仕組みの位置づけはORMとは?O/Rマッパーの仕組み・メリットとデメリットをSQL比較と実行結果で解説で整理しています。
本体とドライバは別モジュールです。この記事の実測は次の組み合わせで行いました。再現時は下記に加え、go get github.com/jackc/pgx/[email protected]でpgxの版も固定します。
go get gorm.io/[email protected]
go get gorm.io/driver/[email protected] // 内部でgithub.com/jackc/pgx/v5 v5.12.0を使用
// 実行環境: go1.27.2 darwin/amd64、PostgreSQL 18.6(UNIXソケット接続)
以降のコードは次のモデルを前提とし、import文を省いた断片で示します。各例は独立した検証で、実測コードでは毎回テーブルを再作成しています。IDの出力まで再現する場合は、検証専用DBでテーブルと採番状態を初期化してから実行します。Emailに一意制約を付け、gorm.DeletedAtでソフトデリートを有効にしています。
type User struct {
ID uint
Name string
Email string `gorm:"uniqueIndex"`
Age int
DeletedAt gorm.DeletedAt
}
dsn := "host=/tmp port=55433 user=postgres dbname=postgres sslmode=disable"
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
一括挿入|CreateとCreateInBatchesの使い分け
スライスを渡すCreateは1文のINSERT
Createにスライスを渡すと、GORMは全件を1文のINSERTにまとめ、採番された主キーを各構造体へ書き戻します。BeforeCreateなどのフックも1件ずつ呼ばれます。
users := []User{
{Name: "u1", Email: "[email protected]"},
{Name: "u2", Email: "[email protected]"},
{Name: "u3", Email: "[email protected]"},
}
result := db.Create(&users)
fmt.Println(result.RowsAffected, users[0].ID, users[1].ID, users[2].ID)
// 出力: 3 1 2 3(INSERTは1回)
1文の大きさは件数に比例するため、入力データ次第で件数が膨らむ処理では、後述のパラメータ上限に実行時まで気づけません。
CreateInBatchesの分割とCreateBatchSize
CreateInBatchesは第2引数の件数ごとにスライスを切り出し、INSERTを複数回に分けます。10件を4件ずつにすると、4件・4件・2件の3回です。
users := make([]User, 10)
for i := range users {
users[i] = User{
Name: fmt.Sprintf("u%d", i+1),
Email: fmt.Sprintf("u%[email protected]", i+1),
}
}
result := db.CreateInBatches(&users, 4)
fmt.Println(result.RowsAffected, users[9].ID)
// 出力: 10 10(INSERTは3回。主キーは最後の要素まで書き戻される)
PostgreSQLでの実SQLは、各バッチがRETURNING "id"付きの複数行INSERTになります。
INSERT INTO "users" ("name","email","age","deleted_at") VALUES ('u1',...),('u2',...) RETURNING "id"
INSERT INTO "users" ("name","email","age","deleted_at") VALUES ('u3',...),('u4',...) RETURNING "id"
INSERT INTO "users" ("name","email","age","deleted_at") VALUES ('u5',...) RETURNING "id"
-- 5件をbatchSize=2で流したときのログ
呼び出しごとに数値を書かずに済ませたいなら、gorm.ConfigかSessionでCreateBatchSizeを決めます。v1.31.2のCreateは先頭でCreateBatchSize > 0を判定し、そのままCreateInBatchesへ処理を渡します(finisher_api.go)。普通のCreate呼び出しも分割挿入に変わるということです。
// 接続全体の既定にする
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{CreateBatchSize: 1000})
// この処理だけ分割する
db.Session(&gorm.Session{CreateBatchSize: 1000}).Create(&users)
Sessionで上書きされるのは正の値だけです。gorm.goはconfig.CreateBatchSize > 0のときにしか設定を写さないため、0を渡してもConfig側の分割は解除できません。一部の処理だけ分割したいなら、Configには入れずSession側だけで指定します。
OnConflictでUpsertするときの競合ターゲット
一括挿入では既存行との衝突がつきものです。clause.OnConflictで衝突時の動作を決めておけば、事前に存在確認のSELECTを回さずに済みます。ここで注意したいのがUpdateAllの既定値です。v1.31.2のcallbacks/create.goには「use primary fields as default OnConflict columns」というコメントがあり、Columnsを省くと競合ターゲットは主キーになります。
// Columnsを省いたUpdateAllが生成するSQL(DryRunで取得)
INSERT INTO "users" (...) VALUES ($1,$2,$3,$4)
ON CONFLICT ("id") DO UPDATE SET "name"="excluded"."name", ... RETURNING "id"
主キーを持たない新しい行を、メールアドレスで照合して上書きするつもりで流すと、衝突するのはidではなくemailなので、次のエラーで止まります。
// 既存: [email protected]
db.Clauses(clause.OnConflict{UpdateAll: true}).
Create(&User{Name: "all", Email: "[email protected]", Age: 3})
// ERROR: duplicate key value violates unique constraint "idx_users_email" (SQLSTATE 23505)
// 競合ターゲットを指定すれば既存行(id=1)が上書きされる
db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "email"}},
UpdateAll: true,
}).Create(&User{Name: "all", Email: "[email protected]", Age: 3})
// RowsAffected=1、id=1の行がname=all・age=3に変わる
// 更新する列を絞る場合
db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "email"}},
DoUpdates: clause.AssignmentColumns([]string{"name", "age"}),
}).Create(&users)
// 衝突した行だけ捨てる(ON CONFLICT DO NOTHING。どの一意制約の衝突でも無視)
db.Clauses(clause.OnConflict{DoNothing: true}).Create(&users)
外部データを繰り返し取り込む処理なら、自然キー(メールアドレスや外部ID)をColumnsに指定するのが基本です。DoNothingで2件中1件が衝突した場合、RowsAffectedは1でした。競合ターゲットの指定がPostgreSQL側でどう解釈されるかはPostgreSQLのUPSERT実装|ON CONFLICTの競合ターゲット指定とMERGE文の使い分けで詳しく扱っています。
CreateInBatchesが途中で失敗したときに巻き戻る範囲
外側のトランザクションが張られる条件
GORMは作成・更新・削除を既定でトランザクションに入れます。作成用のコールバックにgorm:begin_transactionとgorm:commit_or_rollback_transactionが登録されているためで、バッチ1つ分のINSERTはこの既定トランザクションの中で実行されます。
CreateInBatchesはその外側に、全バッチをまとめるトランザクションを足します。v1.31.2のソースは次の分岐です。
if tx.SkipDefaultTransaction || reflectLen <= batchSize {
tx.AddError(callFc(tx.Session(&Session{})))
} else {
tx.AddError(tx.Transaction(callFc))
}
tx.RowsAffected = rowsAffected
件数がbatchSize以下(分割されない)か、SkipDefaultTransactionが有効なときは外側を張りません。それ以外、つまり2バッチ以上に分かれたときだけ、全バッチが1つのトランザクションで包まれます。
3バッチ目で一意制約違反を起こした実測
あらかじめ[email protected]の行を1件入れておき、u1〜u10の10件をbatchSize=4で流しました。10件目が3バッチ目で衝突します。比較として、同じデータを自前のループで4件ずつCreateした場合も測りました。
// A: CreateInBatchesに任せる
err := db.CreateInBatches(&users, 4).Error
// B: 自前で区切って1バッチずつ確定させる
for i := 0; i < len(users); i += 4 {
end := min(i+4, len(users))
if err := db.Create(users[i:end]).Error; err != nil {
break // 失敗したバッチ以降を再投入の対象にする
}
}
| 書き方 | 試行したINSERT | 失敗後の行数 | 残った新規行 |
|---|---|---|---|
| A: CreateInBatches | 3回 | 1行 | 0件 |
| B: 自前ループ | 3回 | 9行 | 8件 |
| A+SkipDefaultTransaction | 3回 | 9行 | 8件 |
Aは3バッチ目のエラー(SQLSTATE 23505)で外側のトランザクションごとロールバックされ、先に成功した8件も消えました。全件入るか1件も入らないかにしたい処理なら、この挙動が適しています。
困るのは、大量データの取り込みで「入った分は残し、失敗したバッチだけ後で流し直す」運用を想定している場合です。その場合はBのように自前で区切ります。SkipDefaultTransactionを有効にしても8件が残りますが、Configで指定すれば接続全体、Sessionで指定してもその処理のフックや関連保存が既定トランザクションの保護から外れます。
batchSizeの決め方|65535パラメータ上限と所要時間
「extended protocol limited to 65535 parameters」が出る条件
batchSizeの上限を決めるのは、1回のINSERTで送るパラメータ数(列数×行数)です。PostgreSQLの拡張クエリプロトコルでは、Bindメッセージのパラメータ数がInt16のフィールドで表されます。pgx v5.12.0はこれを送る前にlen(paramValues) > math.MaxUint16を判定し、超えていればpgconn.goでエラーを返します。
9列のテーブルにスライスを丸ごとCreateして境界を確かめた結果です。
7281 rows x 9 = 65529 params: <nil>
7282 rows x 9 = 65538 params: extended protocol limited to 65535 parameters
このエラーはpgxがパラメータを送る前に返すため、原因の行数はアプリケーション側のログで確認します(直前のステートメント準備などはサーバーに届くことがあります)。対処は、1文あたりのパラメータ数が65,535を下回るbatchSizeでCreateInBatchesを使うことです。同じ9列・7,000件をbatchSize=1,000で流すと、7回のINSERTに分かれてエラーなく入りました。
上限はデータベースごとに違います。
| データベース | 1文のプレースホルダ上限 | 超えたときのエラー |
|---|---|---|
| PostgreSQL(pgx) | 65,535 | extended protocol limited to 65535 parameters |
| MySQL 8.4 | 65,535 | 1390 Prepared statement contains too many placeholders |
| SQLite 3.32.0以降 | 32,766(既定) | too many SQL variables |
MySQLはエラー番号1390(ER_PS_MANY_PARAM)として定義され、SQLiteの既定値はSQLITE_MAX_VARIABLE_NUMBERで3.32.0以降32,766です(それより前は999)。MySQLでは1文のサイズを制限するmax_allowed_packetにも当たり得るため、TEXT列を含むテーブルではパラメータ数より先にこちらで止まることがあります。
batchSize別の所要時間(20,000件の実測)
4列(name・email・age・deleted_at)の20,000件をbatchSizeだけ変えて流し、7回の中央値を取りました。同じマシン上のPostgreSQLにUNIXソケットで接続しているので、往復のコストはネットワーク越しより小さい条件です。
| batchSize | INSERT回数 | 所要時間(中央値) |
|---|---|---|
| 10 | 2,000回 | 283ms |
| 100 | 200回 | 158ms |
| 1,000 | 20回 | 150ms |
| 5,000 | 4回 | 160ms |
10から100に上げると所要時間はほぼ半分になり、100を超えるとほとんど縮みませんでした。ネットワーク越しでは往復遅延も所要時間に加わります。小さいバッチの不利が大きくなる可能性があるため、接続先の環境でもbatchSize別に測定してください。実務の出発点は、列数×batchSizeが上限の半分程度に収まる範囲で数百〜1,000です。10列なら1,000、30列を超えるなら1,000の手前に抑えます。
batchSizeを小さくしても、全バッチを包むトランザクションの途中でロックが解放されるわけではありません。2バッチ以上ある限り外側のトランザクションが全バッチを覆うので、バッチ数が増えるほど開いている時間はむしろ延びます。稼働中のテーブルに流し込んでいて、ほかの更新処理を待たせたくない場合は、前章のBのように1バッチずつコミットする形に変えてください。ロック待ちが連鎖したときの切り分けはデッドロックとは?DBトランザクションで起きる原因・例と解消方法が参考になります。
トランザクションの書き方|Transaction・Begin・SavePoint
Transactionクロージャとtxの取り違え
db.Transactionに渡したクロージャがnilを返すとコミット、errorを返すかpanicするとロールバックされます。BeginとCommitを手で書くより、ロールバック漏れが起きにくい書き方です。
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&User{Name: "in-tx", Email: "[email protected]"}).Error; err != nil {
return err
}
db.Create(&User{Name: "outer-db", Email: "[email protected]"}) // 誤って外側のdbを使った
return errors.New("fail")
})
// 実測: err=fail、テーブルに残ったのは outer-db の1行だけ
クロージャの中で外側のdbを使うと、その文はトランザクションの外にある別のコネクションで実行され、ロールバックしても取り消せません。実測ではクロージャがerrorを返したのにouter-dbの行だけが残りました。*gorm.DBを構造体のフィールドに持つリポジトリでは、この取り違えが起きやすくなります。対策は後述のcontextによる受け渡しで扱います。
BeginとCommitを手で書く場合
トランザクションの開始と終了を個別に制御する必要がある場合は、Beginを使います。この形ではpanic時のロールバックも自分で書く必要があります。
func createWithManualTx(db *gorm.DB) (err error) {
tx := db.Begin(&sql.TxOptions{Isolation: sql.LevelSerializable})
if tx.Error != nil {
return tx.Error
}
defer func() {
if r := recover(); r != nil {
tx.Rollback()
err = fmt.Errorf("panic: %v", r)
}
}()
if err := tx.Create(&User{Name: "m1", Email: "[email protected]"}).Error; err != nil {
tx.Rollback()
return err
}
// ここでpanicが起きても、deferでロールバックされる
return tx.Commit().Error
}
// 実測: Create後に添字範囲外のpanicを起こすと、戻り値はpanic:...、テーブルは0行
returnの経路が増えるほどRollbackの書き漏れが起きます。分離レベルを指定する場合も、db.Transaction(fc, &sql.TxOptions{Isolation: sql.LevelSerializable})のようにTransactionの第2引数で渡す方が安全です。
ネストしたTransactionとSavePoint
トランザクションの中でさらにTransactionを呼ぶと、v1.31.2は内側の開始時にセーブポイントを打ち、内側がerrorを返すかpanicしたときにそこへ巻き戻します(公式ドキュメントのトランザクションにも同じ例があります)。設定DisableNestedTransactionを切り替えて同じコードを流しました。
db.Transaction(func(tx *gorm.DB) error {
tx.Create(&User{Name: "user1", Email: "[email protected]"})
tx.Transaction(func(tx2 *gorm.DB) error {
tx2.Create(&User{Name: "user2", Email: "[email protected]"})
return errors.New("rollback user2")
})
tx.Create(&User{Name: "user3", Email: "[email protected]"})
return nil
})
// DisableNestedTransaction=false: [user1 user3]
// DisableNestedTransaction=true: [user1 user2 user3]
DisableNestedTransaction: trueはセーブポイントを打たなくする設定で、内側の失敗で外側まで巻き戻すようになるわけではありません。実測でも、内側がerrorを返したのにuser2が残りました。内側の失敗で全体を取り消したいなら、内側が返したerrorを外側のクロージャからreturnします。ネストを使わずに一部だけ取り消すなら、tx.SavePoint("sp1")とtx.RollbackTo("sp1")を直接呼ぶ方法もあります。
サービス層のcontextによるtxの受け渡し
Webアプリケーションでは、複数のリポジトリ関数をまたいで1つのトランザクションにしたい場面が出てきます。関数の引数に毎回txを足す方法もありますが、呼び出し階層が深いと書き換えが広がります。context.Contextに*gorm.DBを載せて受け渡すと、リポジトリ側の引数は変えずに済みます。
type txKey struct{}
// ctxにtxが載っていればそれを、なければ通常のdbを返す
func dbFrom(ctx context.Context, db *gorm.DB) *gorm.DB {
if tx, ok := ctx.Value(txKey{}).(*gorm.DB); ok {
return tx.WithContext(ctx)
}
return db.WithContext(ctx)
}
type Service struct{ db *gorm.DB }
func (s *Service) createUser(ctx context.Context, email string) error {
return dbFrom(ctx, s.db).Create(&User{Name: "c", Email: email}).Error
}
func (s *Service) Register(ctx context.Context, a, b string) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
ctx := context.WithValue(ctx, txKey{}, tx)
if err := s.createUser(ctx, a); err != nil {
return err
}
return s.createUser(ctx, b)
})
}
// 実測: Register("c1","c2") → 2行。続けてRegister("c3","c1") → 2件目が一意制約違反になり、c3もロールバックされて2行のまま
この形の弱点は、dbFromを通さずにs.dbを直接使うコードが1か所でもあると、前述の取り違えと同じ漏れが起きることです。リポジトリ層の関数は必ずdbFrom経由でしか*gorm.DBを取らない、とチームで決めておきます。contextの基本はGo言語のcontext入門|BackgroundとTODOの使い分け、キャンセルとタイムアウトの実装、ハンドラ側の組み立てはGin(Go)とは?v1.12.0の最小構成・本番設定と標準net/httpとの使い分けで解説しています。業務システムのデータベース設計やトランザクション境界の見直しを外部に相談したい場合は、データベース設計・移行支援でも対応しています。
SkipDefaultTransactionで外す範囲
公式ドキュメントは、既定トランザクションを無効にすると「about 30%+ performance improvement」が得られると書いています。単発のCreateを3,000回流して9回の中央値を取ったところ、既定のままで720ms、無効にして471msでした(約35%短縮)。1件ごとにBEGINとCOMMITの往復が減る分です。
// 接続全体で無効にする(影響範囲が広い)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{SkipDefaultTransaction: true})
// 速度が必要な処理だけ無効にする
db.Session(&gorm.Session{SkipDefaultTransaction: true}).Create(&logs)
接続全体で外すのは、1文ずつのINSERTしか投げないログ収集のような用途に限ります。全体設定にするとCreateInBatchesの外側トランザクションも張られず、途中の失敗で半端な件数が残ります。速度が必要な処理だけSessionで切り替えてください。
削除|ソフトデリートと全件削除のブロック
モデルにgorm.DeletedAtがあると、DeleteはDELETE文ではなくdeleted_atを書き込むUPDATE文になり、以降の検索から自動的に外れます。削除済みも含めて取得したいときと物理削除したいときは、どちらもUnscopedを挟みます。
db.Delete(&User{}, 10) // UPDATE users SET deleted_at=... WHERE id = 10 AND deleted_at IS NULL
db.Where("age < ?", 18).Delete(&User{}) // 条件指定の削除
db.Unscoped().Where("age = ?", 20).Find(&users) // 削除済みも含めて取得
db.Unscoped().Delete(&user) // 物理削除
err := db.Delete(&User{}).Error
fmt.Println(err, errors.Is(err, gorm.ErrMissingWhereClause))
// 出力: WHERE conditions required true
条件を1つも付けない削除は、GORMが実行前に止めてgorm.ErrMissingWhereClauseを返します。全件削除が本当に必要ならdb.Session(&gorm.Session{AllowGlobalUpdate: true})かdb.Where("1 = 1")で意図を明示しますが、業務コードでこのエラーが出たら、まず条件の変数が空になっていないかを疑うべきです。
ソフトデリートで見落とされやすいのが一意制約です。[email protected]の行をソフトデリートしてから同じメールアドレスで登録し直すと、duplicate key value violates unique constraint "idx_users_email"で失敗しました。削除済みの行は検索から消えても、テーブルには残っているためです。再登録が前提のテーブルでは、部分インデックス(WHERE deleted_at IS NULL)にするか物理削除に寄せます。対策の選び方は論理削除とは:物理削除との使い分けと一意制約・集計が壊れる問題の実装対策で比較しています。
取得・マイグレーション・Generics APIの要点
取得は単一レコードをFirst、複数をFindで行います。Firstは該当なしでgorm.ErrRecordNotFoundを返し、Findは0件でもエラーにならないので、件数はRowsAffectedで見ます。関連レコードをループ内で1件ずつ読むと親の件数だけSELECTが増えます。UserにOrdersのhas-many関連を定義していれば、db.Preload("Orders").Find(&users)で関連側をIN句1回のSELECTにまとめられます(冒頭のモデルには関連が無いので定義の追加が必要です)。発行回数の比較はN+1問題とは?発行クエリ数の実測と検出方法・ORM別の対策にあります。
AutoMigrateはテーブル・カラム・インデックスを作成しますが、使われなくなったカラムを削除しません。カラム名の変更は新カラムの追加として扱われ、旧カラムが残り続けます。リネームや削除が出てきた段階で、Gooseの使い方|Go製マイグレーションツールv3.29のコマンドと本番運用のようなバージョン管理されたマイグレーションへ切り替えます。
v1.30.0(2025年5月25日公開)からはgorm.G[T](db)を起点に型付きで書けるGenerics APIも使えます。クロージャ内ではgorm.G[User](tx).Create(ctx, &user)のようにtxを渡せば、ここまでのトランザクションの挙動がそのまま当てはまります。従来APIとの違いはGORMのGenerics APIとは?従来APIとの違いと基本の実装パターンで解説しています。
よくある質問
GORMで一括挿入(bulk insert)するにはCreateとCreateInBatchesのどちらを使いますか?
件数が固定で数十件までなら、スライスをCreateに渡す1文のINSERTで十分です。件数が入力データ次第で増える処理ではCreateInBatchesを使い、列数×batchSizeが65,535を下回るように分割します。gorm.ConfigにCreateBatchSizeを設定すれば、既存のCreate呼び出しも書き換えずに分割挿入になります。
「extended protocol limited to 65535 parameters」はどう直しますか?
1文のINSERTに含まれるパラメータ数(列数×行数)が65,535を超えたときにpgxドライバが返すエラーです。スライスを丸ごとCreateしている箇所をCreateInBatchesに変え、batchSizeを65,535÷列数より小さくします。9列なら7,281行が上限で、実測では7,282行でこのエラーになりました。
CreateInBatchesの途中で失敗したら、先に入ったバッチはどうなりますか?
既定設定(SkipDefaultTransaction=false)で2バッチ以上に分かれていれば、全バッチを包むトランザクションにより、先に成功したバッチも巻き戻ります。10件をbatchSize=4で流し3バッチ目で一意制約違反を起こした実測では、新規行は1件も残りませんでした。入った分を残したいなら、自前でスライスを区切って1バッチずつCreateします。
gorm.Sessionは何に使いますか?
接続全体の設定(gorm.Config)を変えずに、特定の処理だけ設定を切り替えるために使います。db.Session(&gorm.Session{CreateBatchSize: 1000})で分割挿入、SkipDefaultTransaction: trueで既定トランザクションの省略、DryRun: trueで実行せずにSQLだけを組み立てる、といった使い方です。CreateBatchSizeは正の値だけが反映される点に注意してください。
GORMの読み方に決まりはありますか?
gorm.ioの公式サイトとドキュメントには、読み方や名称の由来の記載はありません(2026年11月確認)。サイトのタグラインは「The fantastic ORM library for Golang, aims to be developer friendly.」で、Go向けのORMライブラリという位置づけだけが示されています。