はじめに
こんにちは、EventHubの成瀬です。
TypeORMでデータを保存するとき、 save() / insert() / update() / upsert() の使い分けに迷ったことはないでしょうか?あるいはINSERTもUPDATEもやってくれる save() をとりあえず使っていませんか?
挙動の違いはなんとなく理解しつつも、「このケースでどれを使うべきか」を根拠とともにはっきり説明しきれないもやっとした感覚がありました。開発中に選択を迷う場面が何度かあったこともあり、一度整理して、発行されるSQLやパフォーマンスの差も把握しておこうと思ったのがこの記事のきっかけです。
この記事では実際に発行されるSQLとベンチマーク結果をもとに、各メソッドの特性と使い分けを整理します。 なお、TypeORMはActive RecordとData Mapperの両パターンをサポートしていますが、本記事ではRepository APIを使うData Mapperパターンを前提としています。
各メソッドの概要
まず各メソッドを簡単に整理します。
| メソッド | 概要 |
|---|---|
save() |
オブジェクトにPKが存在すればUPDATE、なければINSERT |
insert() |
INSERTのみ |
update() |
UPDATEのみ |
upsert() |
INSERT ... ON DUPLICATE KEY UPDATE(新規・更新を1クエリで処理) |
実際に発行されるSQL
TypeORMの logging: ["query"] を有効にして確認しました。
なお、本記事の確認環境はMySQL 8です。他のデータベース(PostgreSQLなど)では発行されるSQLが異なります。
検証に使用したEntityとテーブル
@Entity("samples") class Sample { @PrimaryGeneratedColumn() id!: number; @Column() name!: string; @Column() value!: number; @CreateDateColumn() createdAt!: Date; @UpdateDateColumn() updatedAt!: Date; }
生成されるDDL
CREATE TABLE `samples` ( `id` int NOT NULL AUTO_INCREMENT, `name` varchar(255) NOT NULL, `value` int NOT NULL, `createdAt` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updatedAt` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`) ) ENGINE=InnoDB
save()(新規登録)
-- save() : 1件 query: START TRANSACTION query: INSERT INTO `samples`(`id`, `name`, `value`, `createdAt`, `updatedAt`) VALUES (DEFAULT, ?, ?, DEFAULT, DEFAULT) -- PARAMETERS: ["item-0",0] query: SELECT `Sample`.`id` AS `Sample_id`, `Sample`.`createdAt` AS `Sample_createdAt`, `Sample`.`updatedAt` AS `Sample_updatedAt` FROM `samples` `Sample` WHERE `Sample`.`id` = ? -- PARAMETERS: [1] query: COMMIT -- save([]) : 配列 query: START TRANSACTION query: INSERT INTO `samples`(`id`, `name`, `value`, `createdAt`, `updatedAt`) VALUES (DEFAULT, ?, ?, DEFAULT, DEFAULT) -- PARAMETERS: ["item-0",0] query: SELECT `Sample`.`id` AS `Sample_id`, `Sample`.`createdAt` AS `Sample_createdAt`, `Sample`.`updatedAt` AS `Sample_updatedAt` FROM `samples` `Sample` WHERE `Sample`.`id` = ? -- PARAMETERS: [1] query: INSERT INTO `samples`(`id`, `name`, `value`, `createdAt`, `updatedAt`) VALUES (DEFAULT, ?, ?, DEFAULT, DEFAULT) -- PARAMETERS: ["item-1",1] query: SELECT `Sample`.`id` AS `Sample_id`, `Sample`.`createdAt` AS `Sample_createdAt`, `Sample`.`updatedAt` AS `Sample_updatedAt` FROM `samples` `Sample` WHERE `Sample`.`id` = ? -- PARAMETERS: [2] query: COMMIT
- トランザクション内でINSERTとSELECTが追加され、1回で4クエリです。
- 配列渡しでは1トランザクション内でINSERTとSELECTが1件ずつN回繰り返され、N件で2N+2クエリになります。
save()(既存更新)
-- save() : 1件(差分あり) query: SELECT `Sample`.`id` AS `Sample_id`, `Sample`.`name` AS `Sample_name`, `Sample`.`value` AS `Sample_value`, `Sample`.`createdAt` AS `Sample_createdAt`, `Sample`.`updatedAt` AS `Sample_updatedAt` FROM `samples` `Sample` WHERE `Sample`.`id` IN (?) -- PARAMETERS: [1] query: START TRANSACTION query: UPDATE `samples` SET `value` = ?, `updatedAt` = CURRENT_TIMESTAMP WHERE `id` IN (?) -- PARAMETERS: [100,1] query: SELECT `Sample`.`id` AS `Sample_id`, `Sample`.`updatedAt` AS `Sample_updatedAt` FROM `samples` `Sample` WHERE `Sample`.`id` = ? -- PARAMETERS: [1] query: COMMIT -- save() : 1件(差分なし) query: SELECT `Sample`.`id` AS `Sample_id`, `Sample`.`name` AS `Sample_name`, `Sample`.`value` AS `Sample_value`, `Sample`.`createdAt` AS `Sample_createdAt`, `Sample`.`updatedAt` AS `Sample_updatedAt` FROM `samples` `Sample` WHERE `Sample`.`id` IN (?) -- PARAMETERS: [1] -- save([]) : 配列(差分あり) query: SELECT `Sample`.`id` AS `Sample_id`, `Sample`.`name` AS `Sample_name`, `Sample`.`value` AS `Sample_value`, `Sample`.`createdAt` AS `Sample_createdAt`, `Sample`.`updatedAt` AS `Sample_updatedAt` FROM `samples` `Sample` WHERE `Sample`.`id` IN (?, ?) -- PARAMETERS: [1,2] query: START TRANSACTION query: UPDATE `samples` SET `value` = ?, `updatedAt` = CURRENT_TIMESTAMP WHERE `id` IN (?) -- PARAMETERS: [200,1] query: UPDATE `samples` SET `value` = ?, `updatedAt` = CURRENT_TIMESTAMP WHERE `id` IN (?) -- PARAMETERS: [201,2] query: SELECT `Sample`.`id` AS `Sample_id`, `Sample`.`updatedAt` AS `Sample_updatedAt` FROM `samples` `Sample` WHERE `Sample`.`id` = ? -- PARAMETERS: [1] query: SELECT `Sample`.`id` AS `Sample_id`, `Sample`.`updatedAt` AS `Sample_updatedAt` FROM `samples` `Sample` WHERE `Sample`.`id` = ? -- PARAMETERS: [2] query: COMMIT
- 更新時は存在チェックのSELECTが先頭に加わり、1回で5クエリです。
- 既存エンティティを渡した場合、DB値とオブジェクトで全フィールドの値が同じであればUPDATEを発行せず(ダーティチェック)、トランザクションも開始されません。(
updatedAtも変わりません) - 配列渡しでは存在チェックが1クエリにまとまり、1トランザクション内でUPDATEとSELECTそれぞれN回ずつで、N件で2N+3クエリになります。
insert()
-- insert() : 1件 query: INSERT INTO `samples`(`id`, `name`, `value`, `createdAt`, `updatedAt`) VALUES (DEFAULT, ?, ?, DEFAULT, DEFAULT) -- PARAMETERS: ["item-0",0] query: SELECT `Sample`.`id` AS `Sample_id`, `Sample`.`createdAt` AS `Sample_createdAt`, `Sample`.`updatedAt` AS `Sample_updatedAt` FROM `samples` `Sample` WHERE `Sample`.`id` = ? -- PARAMETERS: [1] -- insert([]) : 配列 query: INSERT INTO `samples`(`id`, `name`, `value`, `createdAt`, `updatedAt`) VALUES (DEFAULT, ?, ?, DEFAULT, DEFAULT), (DEFAULT, ?, ?, DEFAULT, DEFAULT) -- PARAMETERS: ["item-0",0,"item-1",1] query: SELECT `Sample`.`id` AS `Sample_id`, `Sample`.`createdAt` AS `Sample_createdAt`, `Sample`.`updatedAt` AS `Sample_updatedAt` FROM `samples` `Sample` WHERE (`Sample`.`id` = ? OR `Sample`.`id` = ?) -- PARAMETERS: [1,2]
- 件数に関わらず1回あたりINSERTとSELECTの2クエリです。
update()
query: UPDATE `samples` SET `value` = ?, `updatedAt` = CURRENT_TIMESTAMP WHERE `id` IN (?) -- PARAMETERS: [100,1]
- 1回あたりUPDATEの1クエリで、存在チェックはありません。
@UpdateDateColumnがある場合、TypeORMが自動的にupdatedAt = CURRENT_TIMESTAMPをSET句に含めます。
upsert()
-- upsert() : 1件(新規) query: INSERT INTO `samples`(`id`, `name`, `value`, `createdAt`, `updatedAt`) VALUES (DEFAULT, ?, ?, DEFAULT, DEFAULT) ON DUPLICATE KEY UPDATE `id` = VALUES(`id`), `name` = VALUES(`name`), `value` = VALUES(`value`) -- PARAMETERS: ["item-0",0] query: SELECT `Sample`.`id` AS `Sample_id`, `Sample`.`createdAt` AS `Sample_createdAt`, `Sample`.`updatedAt` AS `Sample_updatedAt` FROM `samples` `Sample` WHERE `Sample`.`id` = ? -- PARAMETERS: [1] -- upsert() : 1件(更新) query: INSERT INTO `samples`(`id`, `name`, `value`, `createdAt`, `updatedAt`) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE `id` = VALUES(`id`), `name` = VALUES(`name`), `value` = VALUES(`value`), `createdAt` = VALUES(`createdAt`), `updatedAt` = VALUES(`updatedAt`) -- PARAMETERS: [1,"item-0",100,"2026-04-24T03:49:42.387Z","2026-04-24T03:49:42.387Z"] query: SELECT `Sample`.`id` AS `Sample_id`, `Sample`.`createdAt` AS `Sample_createdAt`, `Sample`.`updatedAt` AS `Sample_updatedAt` FROM `samples` `Sample` WHERE `Sample`.`id` = ? -- PARAMETERS: [1] -- upsert([]):配列(新規) query: INSERT INTO `samples`(`id`, `name`, `value`, `createdAt`, `updatedAt`) VALUES (DEFAULT, ?, ?, DEFAULT, DEFAULT), (DEFAULT, ?, ?, DEFAULT, DEFAULT) ON DUPLICATE KEY UPDATE `id` = VALUES(`id`), `name` = VALUES(`name`), `value` = VALUES(`value`) -- PARAMETERS: ["item-0",0,"item-1",1] query: SELECT `Sample`.`id` AS `Sample_id`, `Sample`.`createdAt` AS `Sample_createdAt`, `Sample`.`updatedAt` AS `Sample_updatedAt` FROM `samples` `Sample` WHERE (`Sample`.`id` = ? OR `Sample`.`id` = ?) -- PARAMETERS: [1,2]
- 件数や新規・更新に関わらず1回あたりINSERTとSELECTの2クエリです。
- MySQLでは
INSERT ... ON DUPLICATE KEY UPDATEに変換されます。 - 補足として、既存レコードの更新時に
save()やupdate()がupdatedAt = CURRENT_TIMESTAMPを自動でセットするのに対し、upsert()はエンティティが持つupdatedAtの値をそのままVALUESに渡すため、updatedAtは自動更新されません。
パフォーマンス計測
実行環境
- CPU: Apple M2 Pro
- Node.js: v22.12.0
- TypeORM: 0.3.14
- DB: MySQL 8(ローカル)
ウォームアップ1回のあと同一操作を5回実行し、中央値を計測結果としています。また各ケースの計測前にテーブルをクリアして初期状態に戻しています。
今回はローカルDB接続での参考値のため、本番環境(リモートDB)ではネットワーク遅延がクエリごとに加算され、特にループ系の差はさらに大きくなることが予想されます。
INSERT系
| 操作 | 100件 | 1000件 |
|---|---|---|
save() ループ(新規登録) |
169ms | 1321ms |
insert() ループ |
118ms | 1019ms |
save([]) 配列(新規登録) |
38ms | 299ms |
insert([]) 配列 |
3.0ms | 24ms |
- 最速は
insert([])、最遅はsave()ループで、1000件の差は約55倍になりました。 - ループ系同士では
save()よりinsert()が速く、差はsave()が1件ごとにSTART TRANSACTION/COMMITを発行することによる余分な2往復のラウンドトリップによるものと考えられます。 insert()ループよりsave([])が速いのは、クエリ数はほぼ同じ(2N vs 2N+2)ながら1トランザクションにまとめることで redoログフラッシュ(innodb_flush_log_at_trx_commit=1のデフォルト動作)がN回→1回に減ることが主な原因として考えられます。autocommitではN回COMMITが走り、そのたびにディスクへの物理書き込みが発生します。save([])よりinsert([])が大幅に速いのは、全件を1クエリにまとめて戻り値取得のSELECTも1回で完結するためです。
UPDATE系
| 操作 | 100件 | 1000件 |
|---|---|---|
save() ループ(既存更新) |
174ms | 1490ms |
update() ループ |
83ms | 774ms |
save([]) 配列(既存更新) |
38ms | 752ms |
upsert([]) 配列 |
5.4ms | 20ms |
- 傾向としてはINSERT系と同様になりました。
- 最速は
upsert([])、最遅はsave()ループで、1000件の差は約75倍になりました。 save([])のINSERT/UPDATEでそれぞれクエリ数はほぼ同じ(2N+2 vs 2N+3)ですが1000件でUPDATE系が遅くなっている理由としては、そもそも単体での処理比較としてINSERTよりUPDATEが遅い(undoログへの旧値書き込みなど)のが積み重なった結果と考えられます。
補足:戻り値取得のSELECTをスキップする
save() や insert() で戻り値が不要な場合はオプションでSELECTをスキップできます。
await repo.save(entity, { reload: false }); // INSERT後のSELECTが消え、1件あたり3クエリになる // START TRANSACTION → INSERT → COMMIT
insert() には直接オプションがないため、クエリビルダ経由で指定します。
await dataSource .createQueryBuilder() .insert() .into(Sample) .values(items) .updateEntity(false) .execute(); // バルクINSERT後のSELECTが消え、常に1クエリになる
INSERT系での計測結果(1000件)
| 操作 | デフォルト | reload:false / updateEntity:false |
|---|---|---|
save() ループ(新規登録) |
1321ms | 1088ms (−17.6%) |
save([]) 配列(新規登録) |
299ms | 151ms (−49.5%) |
insert([]) 配列 |
24ms | 9.1ms (−62.1%) |
SELECTが消えたことでいずれも処理時間が短くなっています。
特に insert([]) はSELECTの対象が WHERE id=1 OR id=2 OR ... OR id=N と件数分展開されるため、大量件数では影響が大きくなります。バッチ処理など後続で生成値を使わないケースでは updateEntity(false) を検討する価値がありそうです。
使い分けガイド
| 用途 | 単体 | 一括 |
|---|---|---|
| 新規のみ | insert() |
insert([]) |
| 更新のみ | update() |
upsert([]) ※ |
| 新規・更新混在 | upsert([]) ※ |
|
| cascade / 更新差分チェック | save() |
save([]) |
- ループ内で1件ずつ処理するのは最もコストが高いパターンです。複数件の一括処理が絡む場面ではまず
insert([])/upsert([])を検討するのが良さそうです。 save()を使いたい場面としては、cascade設定のあるリレーションを一緒に保存したい場合や、更新時の差分チェックをTypeORMに任せたい場合などがありそうです。それ以外の場面では、より明示的かつクエリ数も少なく済むinsert()/update()が選択肢になります。- ※
upsert()は@UpdateDateColumnの自動更新なし。呼び出し前にupdatedAtを明示的にセットする必要があります。
おわりに
各メソッドの発行SQL・挙動の違い・パフォーマンスを一通り整理してみました。
ORMはSQLを抽象化してくれて便利ですが、実際にどんなSQLが走っているかを把握しておくことは、パフォーマンス問題のデバッグや意図しない挙動の回避において依然として重要です。
計測結果からも、特に一括処理では適切なメソッドを選ぶことでパフォーマンスが大きく改善できることが確認できました。
メソッドを使い分けることで、コードの意図も伝わりやすくなるという副次的なメリットもあります。すべて save() ではなく適切な状況で insert() や update() を使うことで「新規専用」「更新専用」という意図がコードから一目でわかるようになります。
今回確認した一部の細かい挙動は公式ドキュメントに明記されていないものもありました。実際の動作を正確に把握したい場合はTypeORMのソースコードを直接読むのが確実です。特に以下のファイルが参考になります。
persistence/SubjectChangedColumnsComputer.ts:ダーティチェックの実装persistence/EntityPersistExecutor.ts:トランザクションスキップの条件query-builder/ReturningResultsEntityUpdator.ts:INSERT/UPDATE後のSELECT発行条件
現在、EventHubではエンジニアを募集しています。本記事を読んで少しでも興味を持ってくださった方は、ぜひカジュアル面談からご連絡ください!









