- 技術 💻
バルクとは?意味とバルクインサートの速さを3つのDBで実測
結論
バルク(bulk)とは「大量」「ひとまとめ」という意味で、IT では大量のデータを1件ずつではなく、まとめて処理することを指します。代表がバルクインサート(複数の行を1回でまとめて登録すること)で、手元の計測では10万行の登録が、1行ずつ毎回コミットする書き方より PostgreSQL で約130倍、MariaDB で約180倍速くなりました。
この記事でわかること
- バルクのふだんの意味と IT の意味(バルク処理・バルクインサート・バルク品・バルクメール)の違い
- PostgreSQL・MariaDB・SQLite で10万行を5つの方法で入れた実測と、速くなる理由
- コミットの単位・1文の行数の上限・途中で失敗したときの挙動を、エラー原文つきで
この記事で実際に動かしたもの:作業用のフォルダーに PostgreSQL 16 と MariaDB 10.11 を 127.0.0.1 で起動し、Python 3.13 から SQLite を含む3つのデータベースへ、同じ注文データ(1万行・2万行・10万行)を「1行ずつ」「まとめて」の5つの方法で登録して、時間・コミットの回数・ログの量・失敗時に残る行数を測りました。
「バルク」は、物流・化粧品・パソコンの部品・メール配信・データベースと、分野ごとに少しずつ違う意味で使われます。共通する芯は「小分けにせず、大きな単位でまとめて扱う」です。IT での使われ方を先に図にします。
flowchart TD
B[バルク<br>大量・ひとまとめ] --> P[バルク処理]
B --> O[バルク品<br>バルクメール]
P --> I[バルク<br>インサート]
P --> A[バルクAPI]
I --> L[COPY<br>LOAD DATA]
よく調べられることの答えを先にまとめます。
| 知りたいこと | 答え | 詳しい節 |
|---|---|---|
| バルクとは(意味) | 英語の bulk。「大量」「かさ」「ひとまとめ」。IT では大量データをまとめて扱うこと | バルクとは |
| バルク処理とは | 多数の件数を1件ずつではなく、まとめた単位で一度に処理すること | バルク処理とは |
| バルクインサートとは | 複数の行を1回の SQL 文(や1回の命令)でまとめてテーブルに登録すること | バルクインサートとは |
| バルクインサートはどれだけ速い? | 10万行で、1行ずつ毎回コミットの33〜107秒に対し、まとめると0.07〜0.3秒 | 3つのDBで実測 |
| 何行ずつまとめればよい? | 手元では1,000行前後で頭打ち。SQLite は8,192行×4列で上限エラー | 1文に入れる行数と上限 |
| 途中で1行失敗したら? | 1つのトランザクションなら全部戻る(0行)。区切ってコミットすると一部だけ残る | 途中で失敗したとき |
| バルク品とは | パソコンの部品などで、箱・説明書を省いてまとめて流通する商品 | 場面別のバルク |
| バルクメールとは | 同じ内容を多数の宛先へ一斉に送るメール。Gmail は1日5,000件超の送信者に追加の要件 | 場面別のバルク |
確認環境:Ubuntu 24.04.5 LTS(2 CPU・メモリ 8GB・ext4 の仮想ディスク。fsync 1回あたり約0.25ミリ秒を pg_test_fsync で計測)の上で、PostgreSQL 16.15(Ubuntu のパッケージ。作業用フォルダーに initdb して 127.0.0.1 のポートで起動、WAL は実ディスクに配置)、MariaDB 10.11.14(Ubuntu の .deb を作業用フォルダーに展開して起動、REDO ログは実ディスクに配置、innodb_flush_log_at_trx_commit=1)、SQLite 3.53.1(Python 3.13.18 の sqlite3 モジュール、実ディスク上のファイル)を使いました。ドライバーは psycopg 3.3.6 と PyMySQL 2.2.8 です。時間は各方法3回の中央値で、1行ずつ毎回コミットする10万行の計測だけは1回です。同じマシンで他の作業も動いているため、数値は2〜5割ほどぶれます。比べるときは桁の違いを見てください。SQL Server の BULK INSERT・Oracle の FORALL・Elasticsearch の Bulk API・Gmail の送信者ガイドラインは、各社の公式ドキュメントで確かめた範囲だけを書いています(確認日 2026年10月29日)。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「バルクとは?意味とバルクインサートの速さを3つのDBで実測」
バルクとは(ふだんの意味とITでの意味)
この節では、「バルク」という言葉の元の意味と、ふだんの会話・業界・IT での意味の違いが分かります。
バルクは英語の bulk で、「大きさ・かさ」「大量」「(包装しない)ばら積み」という意味の言葉です。動詞としては「まとめる・ひとまとめにする」の意味もあり、in bulk で「大量に・まとめて」になります。日本語の「バルク」は、この「大量に・まとめて」の意味で、業界ごとに少し違うものを指します。
| 場面 | 「バルク」が指すもの | 例 |
|---|---|---|
| ふだんの意味 | 大量・まとめ買い・量り売り | バルク買い、バルク食品(量り売りのナッツなど) |
| 物流 | 包装せずにそのまま積む貨物 | 穀物・石炭・鉱石を運ぶバルクキャリア(ばら積み船) |
| 化粧品・食品の製造 | 容器に詰める前の中身 | 化粧水のバルク(充填前の液) |
| ガス | 据え置き型の大きなタンク | LP ガスのバルク貯槽 |
| パソコンの部品 | 箱・説明書を省いて流通する商品 | メモリ・SSD・CPU クーラーのバルク品 |
| IT(データ) | 大量のデータをまとめて処理すること | バルク処理・バルクインサート・バルクロード |
| IT(通信・API) | 複数の操作を1回の通信でまとめて送ること | Bulk API、USB のバルク転送 |
| メール | 同じ内容を多数の宛先へ一斉に送るメール | メールマガジン・お知らせの一斉配信 |
どの場面でも「1つずつ包む・1件ずつ扱う」の反対で、小分けの手間を省いて、大きな単位で効率よく扱うという考え方は同じです。物流のバルク貨物が「梱包の手間と費用を省く」のと、データベースのバルクインサートが「1件ごとの往復と確定(コミット)の手間を省く」のは、同じ発想です。
同じ1万件でも、扱い方で手間が変わる
見分ける問い:「1件ごとに同じ準備と後片付けをしていないか」
場面別の「バルク」:バルク品・バルクメール・バルクAPI
この節では、IT の周りで出会う「バルク品」「バルクメール」「バルクAPI」「バルク転送」が、それぞれ何を指すのかが分かります。
バルク品(パソコンの部品)
バルク品は、メーカーの化粧箱・説明書・付属品を省き、まとめた単位で流通する商品です。もともとはパソコンメーカーなどが大量に仕入れる組み込み用の部品で、店頭では箱入りの「リテール品」と並んで売られます。中身の部品は同じでも、次の点が違うことがあります。
- 保証:メーカー保証が無く、販売店の保証だけのことがある(期間も短いことが多い)。
- 付属品:ネジ・ケーブル・ドライバーのディスク・説明書が付かないことがある。
- サポート:メーカーの問い合わせ窓口の対象外のことがある。
保証の条件は販売店ごとに違うため、販売ページで確かめるのが確実です。
バルクメール(一斉配信のメール)
バルクメールは、同じ内容のメールを多数の宛先へ一斉に送ることや、そのメールを指す言葉です。メールマガジン、会員へのお知らせ、通知メールの一斉配信などが当たります。受け取る側の迷惑メールの判定でも「一斉配信らしさ」は判断材料の1つで、迷惑メールの意味で bulk mail と呼ぶこともあります。
大量に送る側には、受け取る側のサービスが決まりを設けています。Google の「メール送信者のガイドライン」では、Gmail の個人アカウントへ1日5,000件を超えるメールを送る送信者に、SPF・DKIM・DMARC の設定、迷惑メール率を0.3%未満に保つこと、マーケティングのメールでワンクリックの登録解除を用意すること、などを求めています(Google 公式ヘルプ、2026年10月29日時点)。この記事はデータベースが主題なので、配信の手順や設定には立ち入りません。
バルクAPI・バルク転送
Web の API でも「複数の操作を1回のリクエストにまとめる」機能を Bulk API と呼ぶことがあります。たとえば Elasticsearch の Bulk API は、公式ドキュメントで「複数の index・create・delete・update の操作を1回のリクエストで実行する」と説明され、本文は1行に1つの JSON を並べる NDJSON という形です。最適な大きさについては「設定を変えて試し、自分の負荷に合う大きさを見つける」よう案内されています。USB の通信方式にも、大量のデータを送るための「バルク転送」という名前の方式があります。
バルク処理とは(バッチ処理との違い)
この節では、バルク処理の意味と、よく混同されるバッチ処理・ストリーム処理との違いが分かります。
バルク処理とは、多数の件数を1件ずつではなく、まとめた単位で一度に処理することです。データベースへの大量登録・大量更新・大量削除、ファイルからの一括取り込み、API への一括送信などが当たります。
似た言葉の「バッチ処理」は、いつ処理するか(夜間などに、ためておいたデータを決まった時刻にまとめて流す)の言葉です。バルク処理はどう処理するか(1件ずつではなく塊で)の言葉です。夜間バッチの中で、データベースにはバルクインサートで書き込む、というように両方が一緒に使われることがよくあります。
| 言葉 | 何についての言葉か | 反対の言葉 | 例 |
|---|---|---|---|
| バルク処理 | 処理の単位(まとめて扱う) | 1件ずつの処理(row by row) | 10万行を COPY で一度に登録 |
| バッチ処理 | 処理のタイミング(ためて決まった時刻に) | オンライン処理・リアルタイム処理 | 毎晩2時に売上を集計 |
| ストリーム処理 | データが届くたびに流れるように処理 | バッチ処理 | アクセスログを数秒ごとに集計 |
| ミニバッチ | 小さな塊に分けて順に処理 | 全件を一度に | 1,000行ずつ区切ってコミット |
バルクインサートとは(書き方の4つの型)
この節では、バルクインサートの意味と、データベースごとの代表的な書き方が分かります。
バルクインサートとは、複数の行を1回の SQL 文、または1回の命令でまとめてテーブルに登録することです。1行ずつ INSERT 文を送るのに比べ、送受信・文の解析・確定(コミット)の回数が減るため速くなります。書き方は大きく4つの型に分かれます。
複数行 VALUES
INSERT … VALUES (…),(…),(…)。1つの文に何行も並べる。多くの DB で使える。
SQL だけで書ける
executemany・バッチ
1行用の文に、値の組をまとめて渡す。ドライバーが中でまとめ方を工夫する。
プログラムから使う
専用の一括読み込み
PostgreSQL の COPY、MySQL・MariaDB の LOAD DATA、SQL Server の BULK INSERT。
いちばん速い
INSERT … SELECT
別のテーブルの検索結果をそのまま登録する。データが DB の中にあるときに使う。
通信が発生しない
計測で使った注文テーブルと、それぞれの型の書き方です。まず、複数行 VALUES の SQL です。
CREATE TABLE orders(
id bigserial PRIMARY KEY,
user_id int NOT NULL,
item text NOT NULL,
price int NOT NULL,
created_at timestamp NOT NULL
);
-- 1行ずつ(これを10万回送ると遅い)
INSERT INTO orders(user_id, item, price, created_at) VALUES (1, 'item-00', 100, '2026-10-01 00:00:00');
-- 複数行 VALUES(バルクインサート)
INSERT INTO orders(user_id, item, price, created_at) VALUES
(1, 'item-00', 100, '2026-10-01 00:00:00'),
(2, 'item-01', 137, '2026-10-01 00:00:01'),
(3, 'item-02', 174, '2026-10-01 00:00:02');
Python から3つのデータベースへ入れたときのコードです(計測に使ったものから要点だけを抜き出しました)。
import psycopg, pymysql, sqlite3, csv
rows = [(1, "item-00", 100, "2026-10-01 00:00:00"), ...] # 10万行
# PostgreSQL:COPY(psycopg 3)
with psycopg.connect("host=127.0.0.1 port=55212 dbname=t212db user=postgres") as conn:
with conn.cursor().copy("COPY orders(user_id,item,price,created_at) FROM STDIN") as cp:
for r in rows:
cp.write_row(r)
# with を抜けるとコミットされる
# MariaDB / MySQL:executemany(PyMySQL は複数行 VALUES に書き換えて送る)
conn = pymysql.connect(host="127.0.0.1", port=33212, user="root", database="t212db")
with conn.cursor() as cur:
cur.executemany(
"INSERT INTO orders(user_id,item,price,created_at) VALUES (%s,%s,%s,%s)", rows)
conn.commit()
# SQLite:1つのトランザクションで executemany
conn = sqlite3.connect("bench.db")
with conn: # 抜けるときにコミット
conn.executemany(
"INSERT INTO orders(user_id,item,price,created_at) VALUES (?,?,?,?)", rows)
MariaDB の LOAD DATA は、CSV ファイルを読み込ませる形です。手元の PC のファイルを送るときは LOCAL を付け、サーバーとクライアントの両方で許可(サーバーの local_infile、PyMySQL の local_infile=True)が要ります。
LOAD DATA LOCAL INFILE '/dev/shm/t212/tmp/orders.csv'
INTO TABLE orders
FIELDS TERMINATED BY ','
(user_id, item, price, created_at);
他のデータベース・サービスのバルク機能
名前と書き方はデータベースごとに違います。手元で動かしていないものは、公式ドキュメントで確かめた範囲です。
| 製品 | 複数行の登録 | ファイルからの一括読み込み | 確かめ方 |
|---|---|---|---|
| PostgreSQL | 複数行 VALUES | COPY(サーバー側のファイル)・psql の \copy(手元のファイル) | 実行 |
| MySQL・MariaDB | 複数行 VALUES | LOAD DATA [LOCAL] INFILE | 実行(MariaDB) |
| SQLite | 複数行 VALUES・executemany | sqlite3 コマンドの .import | 実行(.import は公式) |
| SQL Server | 複数行 VALUES | BULK INSERT・bcp コマンド | 公式ドキュメント |
| Oracle Database | PL/SQL の FORALL・INSERT ALL | SQL*Loader・外部表 | 公式ドキュメント |
| DynamoDB | BatchWriteItem(1回25件まで) | S3 からのインポート | 別記事で実行 |
SQL Server の公式ドキュメントは、BULK INSERT を「ユーザー指定の形式でデータベース テーブルまたはビューにデータ ファイルをインポートします」と説明しています。Oracle の FORALL は「1つの DML 文を、VALUES 句と WHERE 句に異なる値を与えて複数回実行する」文で、1件ずつではなくまとめて SQL へ送る仕組みです。
複数行 VALUES の書き方の細部(INSERT SELECT・DEFAULT・RETURNING)は、関連記事「SQL INSERT複数行の書き方|INSERT SELECTと速度比較つき」で扱っています。この記事は「バルクとは何か」と、まとめ方の単位・失敗・ロックの判断に絞ります。
バルクインサートの速さを3つのDBで実測(10万行)
この節では、同じ10万行を5つの方法で入れたときに、どれだけ時間が違うのかが分かります。
1行ずつ毎回コミットする書き方は、PostgreSQL で33秒、MariaDB で55秒、SQLite で107秒かかりました。まとめて入れると、どれも0.3秒以下でした。方法は次の5つです。
- A:1行ずつ INSERT し、1行ごとにコミット(自動コミット)
- B:1行ずつ INSERT するが、全体を1つのトランザクションにまとめる
- C:ドライバーの executemany に全行を渡す(1つのトランザクション)
- D:複数行 VALUES で1,000行ずつ(1つのトランザクション)
- E:専用の一括読み込み(PostgreSQL は COPY、MariaDB は LOAD DATA LOCAL INFILE)
| 方法(10万行) | PostgreSQL 16 | MariaDB 10.11 | SQLite 3.53 |
|---|---|---|---|
| A 1行ずつ・毎回コミット | 32.917秒 | 55.127秒 | 106.721秒 |
| B 1行ずつ・1トランザクション | 6.680秒 | 6.228秒 | 0.120秒 |
| C executemany | 0.830秒 | 0.655秒 | 0.111秒 |
| D 複数行 VALUES(1,000行ずつ) | 1.193秒 | 0.647秒 | 0.073秒 |
| E COPY / LOAD DATA | 0.247秒 | 0.305秒 | (なし) |
1万行でも傾向は同じでした(PostgreSQL は A 3.285秒→E 0.024秒、MariaDB は A 3.079秒→E 0.037秒、SQLite は A 9.225秒→D 0.008秒)。計測に使ったスクリプトの出力の一部です。
pg E n=100000 median=0.247s runs=[0.239, 0.299, 0.247] rows/s=404,151
my E n=100000 median=0.305s runs=[0.317, 0.296, 0.305] rows/s=327,876
sq D n=100000 median=0.073s runs=[0.111, 0.068, 0.073] rows/s=1,375,498
pg A n=100000 median=32.917s runs=[32.917] rows/s=3,038
my A n=100000 median=55.127s runs=[55.127] rows/s=1,814
sq A n=100000 median=106.721s runs=[106.721] rows/s=937
読み取れることは3つです。
- いちばん大きく効くのは「毎回コミットをやめること」(A→B)。SQLite は B だけで107秒が0.12秒になりました。
- サーバー型の DB(PostgreSQL・MariaDB)は、さらに「往復の回数を減らすこと」(B→C・D・E)が効きます。B のままでは6秒台でした。
- 専用の一括読み込み(COPY・LOAD DATA)が最速。PostgreSQL の公式ドキュメントも「一連の INSERT の代わりに COPY で全行を1つの命令で読み込む」ことを勧めています。MySQL の公式マニュアルは、LOAD DATA は「通常、INSERT 文より20倍速い」と書いています。
なお、PostgreSQL では D(複数行 VALUES)が C(executemany)より遅い回がありました。psycopg 3.1 以降の executemany は、公式ドキュメントによると内部で「パイプラインモード」(応答を待たずに次の文を送る仕組み)を使います。PostgreSQL へ大量に入れるなら、複数行 VALUES の文を組み立てるより、COPY か executemany を使うほうが手軽で速いという結果でした。
なぜバルクにすると速くなるのか(往復・解析・コミット)
この節では、1行ずつの登録で時間がかかる3つの原因と、バルクインサートがそれぞれをどう減らすのかが分かります。
1行の INSERT には、行を書く本体の仕事の前後に「送る・解析する・確定する」の3つの手間が付きます。10万行を1行ずつ送ると、この手間が10万回繰り返されます。
送る(ネットワークの往復)
アプリが文を送り、DB が「終わった」と返すまで待つ。127.0.0.1 でも1往復に数十マイクロ秒かかり、別のサーバーなら1ミリ秒前後かかることもある。
解析する(SQL の解釈と計画)
DB は文を読み、どう実行するかを決める。複数行 VALUES は1文の解析で多くの行を扱え、COPY は SQL の解析そのものがほぼ無い。
確定する(コミットとディスクへの書き出し)
コミットのたびに、DB は変更の記録(PostgreSQL の WAL、InnoDB の REDO ログ、SQLite のジャーナル)をディスクへ確実に書き出す(fsync)。これがいちばん重い。
SQLite で A が特に遅いのは、サーバーを持たない代わりに、コミットのたびにジャーナルファイルとデータベースファイルの両方を fsync するためです。1行あたり約1ミリ秒(106.721秒÷10万行)で、手元の fsync 1回(約0.25ミリ秒)の数回分にあたります。反対に、通信が無いので B 以降はサーバー型より1桁速くなりました。
行を書く本体の仕事(ページへの書き込み・インデックスの更新)は、どの方法でも同じだけ発生します。バルクインサートが減らすのは、本体の前後に付く「繰り返しの手間」で、本体の仕事そのものではありません。インデックスが多いテーブルでは、本体の仕事の割合が大きくなります(後の節で計測します)。
トランザクションの単位:何行ごとにコミットするか
この節では、コミットの間隔を変えると時間がどう変わるかと、どこで頭打ちになるかが分かります。
1行ずつの INSERT のまま、コミットの間隔だけを1行・10行・100行・1,000行・全部と変えて2万行を入れました。100〜1,000行ごとのコミットで、全部を1回にした場合とほぼ同じ速さになりました。
| コミットの間隔(2万行) | コミット回数 | PostgreSQL | MariaDB | SQLite |
|---|---|---|---|---|
| 1行ごと | 20,000回 | 11.178秒 | 6.236秒 | 21.515秒 |
| 10行ごと | 2,000回 | 2.120秒 | 2.188秒 | 1.877秒 |
| 100行ごと | 200回 | 1.595秒 | 1.398秒 | 0.221秒 |
| 1,000行ごと | 20回 | 1.143秒 | 1.151秒 | 0.046秒 |
| 全部で1回 | 1回 | 1.062秒 | 1.154秒 | 0.027秒 |
この表の PostgreSQL の「1行ごと」(2万行で11.178秒)は、前の節の A(自動コミット、2万行で5.364秒)より遅くなっています。psycopg で明示的に commit() を呼ぶと、1行ごとに「BEGIN・INSERT・COMMIT」の3往復になるためです。自動コミットなら1往復で済みます。「1行ずつコミット」をするなら、明示的なトランザクションより自動コミットのほうが速いという、少し意外な結果です。
では、いつも「全部で1回」がよいかというと、そうとは限りません。1つのトランザクションが大きいほど、次のことが起きます。
- 失敗したときに全部が取り消され、最初からやり直しになる(次の節)。
- ロックを持つ時間が長くなり、他の処理を待たせることがある(その次の節)。
- 取り消し用の記録(PostgreSQL の以前の版の行、InnoDB の undo ログ)が溜まる。手元の MariaDB では、何度も大量登録を繰り返すうちに、取り消し用の記録が入る ibdata1 が 12MB から 76MB まで増えました。
トランザクションの境界を業務の単位でどう決めるか、10万件の更新を一括と1,000件ごとで比べた結果は、関連記事「トランザクション設計の考え方|境界の決め方と長い処理の分割手順」で扱っています。
1文に入れる行数(バッチサイズ)と上限エラー
この節では、複数行 VALUES で1文に何行まとめると速いのかと、まとめすぎたときに出るエラーが分かります。
1つのトランザクションの中で、1文に入れる行数(バッチサイズ)を10行から20,000行まで変えて10万行を入れました。速さは100〜1,000行あたりで頭打ちになり、それより大きくしても速くならず、DB ごとの上限でエラーになりました。
| 1文の行数(10万行) | PostgreSQL | MariaDB | SQLite |
|---|---|---|---|
| 10行 | 1.402秒 | 1.336秒 | 0.083秒 |
| 100行 | 1.485秒 | 0.725秒 | 0.064秒 |
| 1,000行 | 1.379秒 | 0.550秒 | 0.062秒 |
| 5,000行 | 1.375秒 | 0.762秒 | 0.076秒 |
| 8,191行 | 1.520秒 | 0.606秒 | 0.063秒 |
| 8,192行 | 1.455秒 | 0.520秒 | エラー |
| 16,383行 | 1.793秒 | 0.667秒 | エラー |
| 16,384行 | エラー | 0.617秒 | エラー |
エラーになった境目は、どちらも「1文に入れられるパラメーター(? や %s の数)の上限」でした。1行に4列なので、行数×4がパラメーターの数です。
sq batch=8192 ERROR sqlite3.OperationalError: too many SQL variables
pg batch=16384 ERROR psycopg.OperationalError: sending query and params failed: number of parameters must be between 0 and 65535
- SQLite:8,191行×4列=32,764 は通り、8,192行×4列=32,768 で失敗しました。SQLite の公式の「Limits」のページでは、パラメーターの最大数 SQLITE_MAX_VARIABLE_NUMBER の既定値は、3.32.0(2020年5月)より前は999、それ以降は32,766です。
- PostgreSQL:16,383行×4列=65,532 は通り、16,384行×4列=65,536 で失敗しました。PostgreSQL の通信の決まり(プロトコル)では、パラメーターの数を16ビットの数で送るため、65,535 個までです。
- MariaDB:PyMySQL はパラメーターを手元で SQL 文に埋め込んでから送るため、数の上限は無く、代わりに「1回に送れる大きさ」の上限
max_allowed_packetに当たります(次の小見出し)。
MariaDB の max_allowed_packet を超えたとき
上限を 1MB に下げ、5万行(2,184,437バイト)を1文で送りました。アプリ側には「サーバーがいなくなった」というエラーだけが返り、本当の理由はサーバーのエラーログにだけ残りました。
OperationalError: (2006, "MySQL server has gone away (ConnectionResetError(104, 'Connection reset by peer'))")
(MariaDB のエラーログ)
[Warning] Aborted connection 153 to db: 't212db' user: 'root' host: '127.0.0.1' (Got a packet bigger than 'max_allowed_packet' bytes)
「MySQL server has gone away」はネットワークの切断やタイムアウトでも出る文言なので、バルクインサートの最中に出たら、まず1文の大きさを疑うと早く見つかります。MariaDB 10.11 の既定値は 16MB(16,777,216)でした。上限を上げるより、1文を数百〜数千行に区切るほうが、メモリの使い方も安定します。
- 1まず1,000行ずつで始める手元の3つの DB では、ここでほぼ頭打ちだった
- 2行数×列数がパラメーターの上限を超えないか見るSQLite 32,766・PostgreSQL 65,535
- 31文のバイト数が max_allowed_packet を超えないか見る長い文字列や JSON の列があると早く当たる
- 4自分のデータで数通り測って決める列の数・行の大きさ・インデックスで最適な値は変わる
バルクインサートが途中で失敗したらどうなるか
この節では、1,000行のうち1行だけが制約に違反したときに、何行が残るのかが分かります。
551行目の item を空(NULL)にして、NOT NULL の制約に違反させました。全部を1つのトランザクションにすると0行、100行ごとにコミットすると900行が残りました。3つの DB で結果は同じでした。
| やり方(1,000行・100行ずつ) | 残った行 | エラーの原文 |
|---|---|---|
| PostgreSQL・1トランザクション | 0行 | NotNullViolation: null value in column "item" of relation "orders" violates not-null constraint |
| PostgreSQL・100行ごとにコミット | 900行 | 同上 |
| MariaDB・1トランザクション | 0行 | IntegrityError: (1048, "Column 'item' cannot be null") |
| MariaDB・100行ごとにコミット | 900行 | 同上 |
| SQLite・1トランザクション | 0行 | IntegrityError: NOT NULL constraint failed: orders.item |
| SQLite・100行ごとにコミット | 900行 | 同上 |
100行ごとにコミットした場合、失敗した文(501〜600行目)の100行がまるごと抜け、その前後は登録されました。「どの範囲が抜けたか」を記録しておかないと、後から足りない行を特定できません。1つの文の中では、1行でも違反があれば、その文の全行が登録されない点も確かめられました。
PostgreSQL では、トランザクションの中でエラーが起きると、ロールバックするまで次の文を受け付けません。エラーを無視して続けようとしたときの原文です。
psycopg.errors.InFailedSqlTransaction: current transaction is aborted, commands ignored until end of transaction block
1つのトランザクションにまとめる
1行でも失敗すると全部取り消し(0行)
中途半端な状態が残らない
直したら、最初から入れ直せばよい
区切ってコミットする
失敗した塊だけが抜ける(900行)
ロックが短く、やり直しの量が少ない
抜けた範囲の記録と、入れ直しの仕組みが要る
SQL Server の BULK INSERT にも同じ考え方の設定があり、公式ドキュメントでは BATCHSIZE について「それぞれのバッチは、1 回のトランザクションでサーバーにコピーされます」「既定では、指定のデータ ファイル内にあるすべてのデータが 1 つのバッチになります」と説明されています。
重複する行を飛ばす(ON CONFLICT・INSERT IGNORE)
同じ id を2回含む1,001行を1文で入れると、3つの DB とも0行で失敗しました。重複を飛ばす書き方にすると、1,000行が登録されました。
| DB | 重複を飛ばす書き方 | 結果 |
|---|---|---|
| PostgreSQL | INSERT … ON CONFLICT (id) DO NOTHING |
1,000行。何も知らせずに飛ばす |
| MariaDB | INSERT IGNORE … |
1,000行。警告 1062 Duplicate entry '500' for key 'PRIMARY' |
| SQLite | INSERT OR IGNORE … |
1,000行。何も知らせずに飛ばす |
MariaDB の INSERT IGNORE は、重複だけでなく型の変換などのエラーも警告に変えて登録を続けます。SHOW WARNINGS を見ないと、何が飛ばされたか分かりません。PostgreSQL で重複時に上書きする ON CONFLICT … DO UPDATE は、1つの文の中に同じキーが2回あると次のエラーになりました。
CardinalityViolation: ON CONFLICT DO UPDATE command cannot affect row a second time
HINT: Ensure that no rows proposed for insertion within the same command have duplicate constrained values.
バルクで入れる前に、アプリ側で同じキーを1つにまとめておくと避けられます。
投入中のロックと他の処理への影響
この節では、大量登録の途中(コミット前)に、別の接続から読んだり書いたりすると何が起きるかが分かります。
PostgreSQL は、コミット前の10万行を別の接続から読むと0行に見え、待たされませんでした。SQLite は、未コミットの行が多いと、別の接続からは読むことも書くこともできませんでした。
| 状態(コミット前) | 別接続から SELECT | 別接続から INSERT など |
|---|---|---|
| PostgreSQL・COPY で10万行 | すぐ返る(count は 0) | INSERT はすぐ通る。TRUNCATE はロック待ち |
| SQLite(既定)・未コミット1,000行 | すぐ返る(count は 0) | database is locked |
| SQLite(既定)・未コミット10万行 | database is locked | database is locked |
| SQLite(WAL モード)・未コミット10万行 | すぐ返る(count は 0) | database is locked |
PostgreSQL では、COPY 中のテーブルに付くロックは RowExclusiveLock で、普通の INSERT・UPDATE と同じ強さです。テーブルを丸ごと消す TRUNCATE だけがぶつかり、待ち時間の上限(lock_timeout)を1秒にすると次の原文で止まりました。
別接続の count(*): 0
ロック: [('RowExclusiveLock', True)]
別接続の TRUNCATE: LockNotAvailable: canceling statement due to lock timeout
コミット後の count(*): 100000
SQLite(既定のジャーナルモード)では、未コミットの変更がメモリのキャッシュ(既定で約2MB)に収まるうちは、他の接続も読めます。収まらなくなると、SQLite は変更をファイルへ書き出すために、ファイル全体の排他ロックを取ります。そのため10万行のときだけ、読み取りも次の原文で失敗しました(待ち時間を0秒にして確かめています)。
journal_mode=delete 未コミット100000行 別接続のSELECT: OperationalError: database is locked
journal_mode=wal 未コミット100000行 別接続のSELECT: OK (0,)
PRAGMA journal_mode=WAL にすると、書き込みの最中でも他の接続は読めました。ただし書き込みは、どのモードでも同時に1つだけです。SQLite で大きなバルクインサートをするときは、他の処理が止まる時間を見込んでおくか、区切ってコミットします。
インデックス・WAL・ログ:大量投入で増えるもの
この節では、バルクインサートで速さを左右するインデックスと、ディスクに書かれる記録の量が分かります。
インデックスは後から作るほうが速い
インデックスを3つ付けたテーブルに COPY で10万行を入れると0.523秒、インデックス無しで入れてから3つ作ると、作成まで含めて0.324秒でした。
| やり方(PostgreSQL・COPY 10万行) | 投入 | インデックス作成まで含む合計 |
|---|---|---|
| インデックス3つを先に作っておく | 0.523秒 | 0.523秒 |
| 投入してからインデックス3つを作る | 0.186秒 | 0.324秒 |
| インデックス無し(主キーだけ) | 0.230秒 | 0.230秒 |
PostgreSQL の公式ドキュメント「データベースへのデータ投入」でも、新しく作ったテーブルなら「テーブルを作り、COPY でデータを入れ、それから必要なインデックスを作る」のが最速と書かれています。一方で、運用中のテーブルでインデックスを消すと、その間の検索が遅くなり、一意のインデックスなら重複を防げなくなります。消してよいのは、空のテーブルへの初回の投入や、他から使われていない作業用のテーブルのときです。
WAL(変更の記録)の量
PostgreSQL は、変更をデータファイルに書く前に WAL という記録に書きます。2万行を入れる前後で WAL の位置の差を測りました。
| 方法(PostgreSQL・2万行) | 時間 | WAL の量 |
|---|---|---|
| A 1行ずつ・毎回コミット | 5.364秒 | 3.8MB |
| B 1行ずつ・1トランザクション | 1.469秒 | 3.0MB |
| C executemany | 0.240秒 | 3.0MB |
| D 複数行 VALUES | 0.266秒 | 3.0MB |
| E COPY | 0.045秒 | 2.1MB |
WAL の量は、方法による差より「行の量」でほぼ決まりました(毎回コミットはコミットの記録が増え、COPY は記録がまとまって少なくなります)。速さの差は WAL の量ではなく、WAL をディスクへ確定させる回数の差だと分かります。大量投入では WAL が溜まってチェックポイント(データファイルへの書き出し)が頻繁に起きるため、公式ドキュメントは投入中だけ max_wal_size を一時的に大きくすることも挙げています。
- 1新しいテーブルなら、インデックスと外部キーは投入の後に作る手元では投入+作成で 0.523秒→0.324秒
- 2投入の後に ANALYZE を流す統計情報が投入前のままだと、検索の計画が外れる。公式ドキュメントも強く勧めている
- 3ディスクの空きを見ておくWAL・REDO ログ・取り消し用の記録が、データ本体とは別に増える
- 4レプリカやバックアップへの影響を確かめる大量の WAL がレプリカへ送られ、遅れが出ることがある
バルクインサートでよくあるエラー・つまずき
この節では、この記事の計測で実際に出たエラーの原文と、原因・直し方が一覧で分かります。
| エラーの原文 | DB | 原因 | 直し方 |
|---|---|---|---|
| too many SQL variables | SQLite | 1文のパラメーターが32,766を超えた | 行数×列数が上限内に収まるよう区切る |
| number of parameters must be between 0 and 65535 | PostgreSQL | 1文のパラメーターが65,535を超えた | 区切る、または COPY を使う |
| (2006, "MySQL server has gone away …") | MariaDB | 1文が max_allowed_packet より大きい(サーバーのログに Got a packet bigger than) | 1文を小さく区切る。サーバーのエラーログを見る |
| current transaction is aborted, commands ignored until end of transaction block | PostgreSQL | トランザクション中のエラーの後に、ロールバックせず次の文を送った | エラーを受けたら rollback() してからやり直す |
| ON CONFLICT DO UPDATE command cannot affect row a second time | PostgreSQL | 1文の中に同じキーが2回ある | 送る前にキーを1つにまとめる |
| database is locked | SQLite | 別の接続が書き込み中(大きい変更だと読み取りも) | 区切ってコミットする、WAL モード、timeout を設定する |
| canceling statement due to lock timeout | PostgreSQL | 投入中のテーブルに TRUNCATE などの強いロックを求めた | 投入が終わってから実行する |
| (1062, "Duplicate entry '500' for key 'PRIMARY'") | MariaDB | 主キーの重複。1文の全行が登録されない | 重複を除く、または INSERT IGNORE・ON DUPLICATE KEY UPDATE |
エラーにならないつまずきもあります。
やってはいけない → 代わりに
「速くするために自分で SQL を組み立てる」必要は、ほとんどの場合ない
やってはいけない → 代わりに
手元の例では、黙っていると1,000行のうち100行が抜けたまま終わった
CSV ファイルを PostgreSQL・MySQL に取り込むときのエラー(文字コード・区切り・権限)は、関連記事「PostgreSQL CSVインポート|COPYと\copyの違いとエラー対処」と関連記事「MySQL CSVインポート|LOAD DATA INFILEとエラー解決」で扱っています。この記事の COPY・LOAD DATA は速さの比較のために使い、オプションやエラー対処はそちらに任せます。
判断表:どのバルクインサートを選ぶか
この節では、データのある場所・量・失敗したときの扱いから、どの方法を選べばよいかが分かります。
| 状況 | 選ぶ方法 | 理由(手元の計測) |
|---|---|---|
| 数十〜数百行を画面の操作で登録 | executemany か複数行 VALUES を1トランザクションで | この量なら差はミリ秒単位。書きやすさを優先 |
| 数万〜数百万行を CSV から(PostgreSQL) | COPY・\copy | 10万行 0.247秒で最速 |
| 数万〜数百万行を CSV から(MySQL・MariaDB) | LOAD DATA [LOCAL] INFILE | 10万行 0.305秒で最速 |
| プログラムの中で作ったデータ(MySQL・MariaDB) | PyMySQL などの executemany | 複数行 VALUES に書き換えて送り、0.655秒 |
| プログラムの中で作ったデータ(SQLite) | BEGIN〜COMMIT でくくって executemany | くくるだけで107秒→0.1秒 |
| 全部入るか、全部入らないかにしたい | 1つのトランザクション | 1行の違反で0行に戻った |
| 他の処理を長く止めたくない・途中から再開したい | 1,000行前後ずつ区切ってコミット+範囲の記録 | 1,000行ごとで全部1回とほぼ同じ速さ |
| データがすでに同じ DB の別のテーブルにある | INSERT … SELECT | アプリとの往復が発生しない |
| DynamoDB などの NoSQL・クラウドのサービス | 各サービスのバッチ API(件数の上限と再送の仕組みに従う) | 1回の件数と失敗時の扱いがサービスごとに違う |
DynamoDB の一括登録(BatchWriteItem の25件の上限、未処理の項目の再送、トランザクションとの違い)は、関連記事「DynamoDB一括登録とトランザクション|BatchWriteItemとの違い」で扱っています。
区切ってコミットするときの骨組みです。失敗した範囲を記録し、最後に件数を照らし合わせます。
import logging
def bulk_insert(conn, rows, batch=1000):
sql = "INSERT INTO orders(user_id,item,price,created_at) VALUES (%s,%s,%s,%s)"
ok, failed = 0, []
for start in range(0, len(rows), batch):
chunk = rows[start:start + batch]
try:
with conn.cursor() as cur:
cur.executemany(sql, chunk)
conn.commit()
ok += len(chunk)
except Exception as e:
conn.rollback()
failed.append((start + 1, start + len(chunk)))
logging.error("rows %d-%d failed: %s", start + 1, start + len(chunk), e)
logging.info("inserted=%d failed_ranges=%s total=%d", ok, failed, len(rows))
return ok, failed
バルクに関するよくある質問
この節では、バルクとバルクインサートについて、よく聞かれる質問に短く答えます。
Q. 「バルク」と「一括」は同じ意味ですか?
ほぼ同じです。IT では「一括登録」「一括更新」を英語風に「バルクインサート」「バルクアップデート」と呼びます。物流や化粧品の「バルク」は「包装しない・容器に詰める前」の意味が強く、「一括」とは少しずれます。
Q. バルクインサートとバッチインサートは違いますか?
多くの場合、同じものを指します。ドライバーやフレームワーク(JDBC の executeBatch など)では「バッチ」、データベースの機能名(SQL Server の BULK INSERT など)では「バルク」と呼ぶ傾向があります。
Q. 何行からバルクインサートにすべきですか?
決まった境目はありません。手元の計測では、1万行でも1行ずつ毎回コミットは3〜9秒、まとめると0.01〜0.04秒でした。数百行を超えてループで INSERT しているなら、まとめる価値があります。
Q. 1文に何行まとめるのがよいですか?
手元の3つの DB では、1,000行前後で速さが頭打ちになりました。それより大きくすると、SQLite は8,192行×4列で、PostgreSQL は16,384行×4列で上限エラーになりました。
Q. バルクインサートの途中で失敗すると、データはどうなりますか?
1つのトランザクションなら全部取り消されます(手元では0行)。区切ってコミットしていれば、失敗した塊だけが抜けます(手元では1,000行中900行が残りました)。
Q. ORM(Django・Rails・Spring Data など)でもバルクインサートできますか?
多くの ORM に、まとめて登録する機能があります(Django の bulk_create、Rails の insert_all など)。1件ずつ save を呼ぶ書き方とは、送られる SQL が違います。発行される SQL をログで確かめると、まとめて送られているかが分かります。
Q. バルクインサートでは自動採番の id やトリガーはどうなりますか?
自動採番は通常どおり振られます。PostgreSQL では RETURNING で登録した行の id を受け取れます。トリガーは行ごとに動くため、トリガーや外部キーが多いテーブルでは、まとめても速くなりにくくなります。
Q. バルクメールと迷惑メールは同じですか?
同じではありません。バルクメールは「一斉配信」という送り方の言葉で、受け取る人が登録したメールマガジンも含みます。ただし大量に送る送信者には、Gmail などが認証・登録解除・迷惑メール率の要件を設けています。
まとめ
- バルク(bulk)とは「大量・ひとまとめ」の意味で、IT では大量のデータを1件ずつではなく、まとめて処理することを指す。物流・化粧品・パソコンの部品(バルク品)・メール(バルクメール)でも、芯は「小分けにせず大きな単位で扱う」。
- バルクインサートは、複数行 VALUES・executemany・COPY / LOAD DATA などで複数行をまとめて登録すること。10万行で、1行ずつ毎回コミットの33〜107秒が、0.07〜0.3秒になった。
- 最大の効果は「毎回コミットをやめる」こと、次が「往復の回数を減らす」こと。最速は COPY・LOAD DATA。PostgreSQL では複数行 VALUES より COPY か executemany が手軽で速かった。
- 1文の行数は1,000行前後で頭打ち。行数×列数がパラメーターの上限(SQLite 32,766・PostgreSQL 65,535)や max_allowed_packet を超えると、エラーや「server has gone away」になる。
- 1つのトランザクションは「全部か無か」、区切ったコミットは「失敗した塊だけ抜ける」。区切るなら範囲を記録し、件数を照らし合わせる。SQLite は大きな未コミットで読み取りまで止まる点に注意する。
参考・出典
確認日はいずれも 2026年10月29日です。
- PostgreSQL 16「14.4. Populating a Database」:https://www.postgresql.org/docs/16/populate.html
- PostgreSQL 16「COPY」:https://www.postgresql.org/docs/16/sql-copy.html
- PostgreSQL 16「55.7. Message Formats」(Bind のパラメーター数):https://www.postgresql.org/docs/16/protocol-message-formats.html
- MySQL 8.4 Reference Manual「Optimizing INSERT Statements」:https://dev.mysql.com/doc/refman/8.4/en/insert-optimization.html
- MariaDB Documentation「LOAD DATA INFILE」:https://mariadb.com/kb/en/load-data-infile/
- MariaDB Documentation「max_allowed_packet」(Server System Variables):https://mariadb.com/kb/en/server-system-variables/#max_allowed_packet
- SQLite「Limits In SQLite」:https://www.sqlite.org/limits.html
- SQLite「Write-Ahead Logging」:https://www.sqlite.org/wal.html
- psycopg 3「Pipeline mode support」:https://www.psycopg.org/psycopg3/docs/advanced/pipeline.html
- psycopg 3「Using COPY TO and COPY FROM」:https://www.psycopg.org/psycopg3/docs/basic/copy.html
- PyMySQL「Cursor Objects」(executemany):https://pymysql.readthedocs.io/en/latest/modules/cursors.html
- Microsoft Learn「BULK INSERT (Transact-SQL)」:https://learn.microsoft.com/ja-jp/sql/t-sql/statements/bulk-insert-transact-sql
- Oracle「FORALL Statement」(PL/SQL Language Reference 19c):https://docs.oracle.com/en/database/oracle/oracle-database/19/lnpls/FORALL-statement.html
- Elastic「Bulk index or delete documents」(Elasticsearch API):https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-bulk
- Google Workspace 管理者ヘルプ「メール送信者のガイドライン」:https://support.google.com/a/answer/81126?hl=ja
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「バルクとは?意味とバルクインサートの速さを3つのDBで実測」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。