TypeORMの保存メソッドをSQLとパフォーマンスで比較してみた

はじめに

こんにちは、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のソースコードを直接読むのが確実です。特に以下のファイルが参考になります。

現在、EventHubではエンジニアを募集しています。本記事を読んで少しでも興味を持ってくださった方は、ぜひカジュアル面談からご連絡ください!

jobs.eventhub.co.jp

note.com

参考リンク