インポート用のスクリプトが数件を続けて挿入し、二件目でキーの重複により例外を送出したとします。画面に「インポート失敗」と表示されると、データベースも元に戻ったと思いがちです。しかし一件目が残っていれば、再実行は途中まで取り込まれたデータを相手にすることになります。SQLite の挿入が失敗すると、どこまで取り消されるのでしょう。複数のレコードをまとめて成功、または失敗させるにはどうすればよいのでしょうか。

答えは三点に依存します。先の操作がすでにコミットされたか、競合時の処理が現在の文だけを取り消すのかトランザクション全体を取り消すのか、そして例外を捕捉したアプリケーションがトランザクションをどう終えるかです。例外は操作中に問題が起きたことを示しますが、変更が一切残っていない証拠にはなりません。一時データベースで状態を分けて観察し、適用範囲の明確な一括書き込み関数を示します。

1. SQL 文、トランザクション、業務上の「一括」を分ける

SQL 文は一回の INSERT や UPDATE などの操作です。トランザクションは複数の操作を一つのコミットまたはロールバックの範囲にまとめます。「一括インポート」は、アプリケーションが求める業務上の完全性です。三つの範囲は一致することも、別々になることもあります。一つの関数に二回の挿入を書くだけでは、一つのデータベーストランザクションにはなりません。

SQLite はアクティブなトランザクションがないとき、データベースにアクセスする文のために自動でトランザクションを開始します。自動で始まったものは、関係する文の終了後にコミットされます。明示的な BEGIN は、その境界を後続の COMMIT または ROLLBACK まで延ばします。したがって、コミット済みの一件目が、次の挿入のエラーで自動的に取り消されることはありません。SQLite:Transaction

一方、トランザクションを開始したからといって、すべてのエラーで一括分が消えるわけでもありません。SQLite の既定の制約競合処理は ABORT です。失敗した現在の文の変更を取り消し、同じトランザクションにある以前の文の変更を残し、トランザクションは続きます。中止の対象は文であり、画面上で「インポート」と呼ぶ業務全体ではありません。SQLite:ON CONFLICT

一件ずつの成功と失敗を許す業務なら、以前のレコードを残す設計が正しい場合もあります。設定項目や関連レコードが一式で存在する必要があるなら、全体をロールバックする必要があります。エラーが出たら一律に何かを選ぶのではなく、どの書き込みが分割できない作業単位なのかを先に定義します。

2. 現在の接続、別の接続、最終結果を同時に見る

2026 年 10 月 3 日に、macOS、Python 3.9.6、実行時 SQLite 3.54.0 で完全な実験スクリプトを実行しました。SQLite のバージョンは sqlite3.sqlite_version から取得し、Python のバージョンから推測していません。すべてのデータベースを一時ディレクトリに新規作成し、実験後に削除しました。谷米のサーバーや既存の業務データベースには接続していません。

スクリプトは journal_mode=DELETE を固定し、接続を isolation_level=None として、SQL で明示的にトランザクション命令を出します。今回の Python の接続方法では、この設定がドライバーの暗黙のトランザクション管理を無効にし、SQLite の動作を直接観察できます。Python 3.9:Controlling Transactions テーブルは次のとおりです。

CREATE TABLE items (
    code TEXT NOT NULL PRIMARY KEY,
    label TEXT NOT NULL
);

各ケースは空のテーブルから始まります。通常のケースはキー a を挿入し、同じキーをもう一度挿入して IntegrityError を起こします。エラーの時点で、トランザクションがアクティブか、書き込み側の接続でどのキーが見えるかを記録し、新たな独立した接続でも照会します。その後はケースに従ってコミットかロールバックを選び、新しい接続から最終結果を読みます。[] は空のテーブル、文字はキーであり、行数ではありません。

ケースエラー時にトランザクションがアクティブこの接続で見えるキー別の接続で見えるキーケースに従って終了した後の結果
autocommit:一括のトランザクションを明示的に開始しないFalse[a][a][a]
abort_then_commit:既定の競合処理、その後コミットTrue[a][][a]
abort_then_rollback:既定の競合処理、その後ロールバックTrue[a][][]
or_rollback:競合時にトランザクション全体をロールバックFalse[][][]
batch_abort:複数行の文に ABORT、その後コミットTrue[seed][][seed]
batch_fail:複数行の文に FAIL、その後コミットTrue[b, seed][][b, seed]
with_exception_out:例外を with ブロックの外へ出すTrue[a][][]
with_exception_caught:with ブロック内で例外を抑えるTrue[a][][a]

特に二行目に注目してください。エラー後も現在の接続からは a が見えますが、別の接続には見えません。まだコミットしていない変更なので、「自分で照会できた」を「コミット済み」と解釈できません。コードがコミットした後、新しい読み取り接続にも a が見えました。三行目はエラー時点で全く同じ状態ですが、その後アプリケーションがロールバックしたため、最終的には空です。

このため、例外の画面を記録したり、元の接続で一度だけ照会したりするだけでは、一括のロールバックを検証できません。その後どの終了経路を通ったかを知り、最終的なコミット状態を確認する必要があります。

3. 複数行 INSERT の失敗にも競合処理の違いがある

文の内部の境界を見るため、二つのケースでは同じトランザクションで先に seed を挿入し、その後、同じキーを二回含む一つの複数行の挿入を実行しました。

INSERT OR ABORT INTO items (code, label)
VALUES ('b', 'first in statement'), ('b', 'duplicate in statement');

実際には、この文の最初の b も取り消されました。しかし、前の文で書いた seed はアクティブなトランザクションに残ります。その後コミットすると seed だけが残ります。「現在の文に部分的な成功がない」ことと、「トランザクション全体に部分的な成功がない」ことは別です。

ABORT を FAIL に変えると、同じ入力でエラーが出た時点で、現在の接続には b と seed が見えました。その後コミットすると両方が残りました。公式の定義でも、適用可能な FAIL の競合処理は、失敗した文のそれまでの変更を残します。ROLLBACK の競合処理は現在のトランザクションを終えてロールバックします。SQLite:競合アルゴリズム

この例を「すべての SQL エラーで一つの文が途中まで残る」と広げたり、OR ROLLBACK を付ければすべての障害を扱えると考えたりしないでください。実際に起こしたのは主キーの一意性違反で、結果は明示した競合処理に依存します。制約やエラーの種類ごとに規則があり、該当する文書の確認が必要です。

アプリケーションにとって重要なのは、コミット前に一括処理の結果を確認することです。ループ、バッチ API、複数行 SQL を「一括操作」と呼ぶだけでは、業務上の原子性は分かりません。確認すべきなのは、その操作が属するトランザクション境界と失敗時の処理です。

4. Python の with でも、失敗した一括分がコミットされる理由

接続のコンテキストマネージャーは、終了経路に従ってコミットかロールバックを選びます。例外が with connection: の外へ伝わればロールバックし、通常の終了ならアクティブなトランザクションをコミットします。また、ブロックを出るだけでは接続を自動的に閉じません。Python 3.9:接続のコンテキストマネージャー

今回の二つの with ケースでは、どちらも内部で BEGIN を明示的に実行しました。これは大切です。暗黙の管理を無効にしたため、with を追加するだけでトランザクションが始まるとは仮定できません。一件目の挿入は成功し、二件目の重複で失敗した時点では、両ケースに未コミットの a がありました。

違いは、例外をさらに伝えるかどうかです。with_exception_out は状態を記録して例外を再送出し、ブロック外で捕捉します。終了時にロールバックし、最終的には空です。with_exception_caught は内部で捕捉して再送出せず、ブロックを通常の経路で終えます。コンテキストマネージャーはコミットし、最後に a が残ります。

ここでエラーを表示したりログを書いたりすることは、ロールバックではありません。抑えられた例外をコンテキストマネージャーは見ず、通常の終了を見ます。一括分を失敗させる必要があるなら、失敗を対応するロールバック経路へ渡す必要があります。コンソールの表示だけ変えても足りません。悪いレコードを飛ばして他を保存する仕様なら、どれが成功したかも明示し、全体が未実行だったかのように画面へ表示しないことです。

5. 一括で成功または失敗する実装を明確にする

添付スクリプトの insert_batch は、一つの最上位トランザクションだけを管理します。接続は実験と同じ設定で、呼び出し時にアクティブなトランザクションがないことを要求します。重要な処理は次のとおりです。INSERT はプレースホルダーでキーとラベルを渡します。

INSERT = "INSERT INTO items (code, label) VALUES (?, ?)"

def insert_batch(connection, batch):
    if connection.in_transaction:
        raise ValueError("insert_batch needs a connection with no active transaction")
    connection.execute("BEGIN")
    try:
        connection.executemany(INSERT, batch)
        connection.execute("COMMIT")
    except BaseException:
        if connection.in_transaction:
            connection.execute("ROLLBACK")
        raise

書き込みとコミットを同じエラー処理の範囲に入れます。失敗し、トランザクションがまだアクティブなら、ロールバックして例外を呼び出し元へ返します。捕捉後の再送出は、上位に失敗を伝え、「後処理完了」を「インポート成功」と誤認させないためです。データベースがすでにロールバックした場合には、状態確認によってアクティブなトランザクションがない状態での再ロールバックも避けます。

既存のトランザクションを先に拒否するのは、呼び出し元がそれまで書き込んだ別のデータを、関数が勝手にロールバックしてはいけないためです。既存のトランザクション内の局所的な作業単位には、保存点などによる別の設計が必要です。この関数は入れ子の管理を実装しておらず、既存のトランザクションのラッパーの中へそのまま組み込むべきではありません。

四つの境界も実際に検査しました。空のバッチは表を変えません。二つの正常なキーはまとめてコミットされ、独立した接続で [a, b] が見えます。重複キーを含むバッチは例外の後で表が空になり、アクティブなトランザクションもありません。呼び出し元に既存のトランザクションがあると関数は実行を拒否し、それまでに書いたキーとトランザクションの状態を保ちます。これらは今回の入力に対するコードの動作を検証し、あらゆるデータベース障害を保証するものではありません。

スクリプトをダウンロードすれば、Python 標準ライブラリだけで直接実行できます。

python3 sqlite-rollback-lab.py --json sqlite-rollback-results.json

今回の出力は All 8 cases and 4 batch-helper checks passed. で終わりました。実際の実行結果の JSONもダウンロードでき、環境、エラー時の状態、最終結果を項目ごとに確認できます。各ケースは別々の新しいデータベースを使い、前のケースの残りが結果に影響しないようにしています。

6. 今回は検証していない障害

一意性の競合は再現しやすい例ですが、障害のすべてではありません。SQLite の文書は、ディスク容量不足、I/O エラー、中断、メモリー不足などで、文だけを取り消す場合と、トランザクション全体の自動ロールバックが必要になる場合があると説明します。今日の ABORT の結果から「あらゆるエラー後にトランザクションが残る」とは導けません。SQLite:トランザクション内のエラー応答

コミットも、必ず成功する最後の一行ではありません。公式文書によれば、別の接続による読み取りなどのために COMMIT が busy エラーを返し、トランザクションがアクティブなままになることがあります。今回の実験はロック競合を起こさず、busy 時の再試行方針も測定せず、ディスク容量不足やロールバック失敗も注入していません。本番のコードには、こうした場合の報告、復旧、接続の扱いを決める必要があり、四つの検査を完全な障害試験とは扱えません。

永続性も別の境界です。独立した接続がコミット結果を読めたことは、今回の通常の実行でコミットされ、参照可能になったことを示します。電源断、システムのクラッシュ、壊れたファイルシステムは模擬していません。WAL モード、共有キャッシュ、外部キーのエラー、トリガーの組み合わせでも再実行していません。範囲を明示するのは、実験がトランザクション制御を扱い、どんな環境でもデータが失われないと宣言するものではないと伝えるためです。

7. 途中まで残ったデータは終了経路から調べる

「失敗と表示されたのにレコードがある」場合は、まず接続設定とトランザクションの開始地点を確認し、実際の SQL の競合処理を調べます。次に、例外がどの層で捕捉され、再送出されたか、最後にコミットまたはロールバックを実行したかを追います。現在の接続のトランザクション状態は経過の理解に役立ちますが、最終結果は適切な新しい接続から確認し、一括処理の対象として期待するレコード集合と照合すべきです。

エラーログの件数をロールバックの証拠にせず、接続を閉じるときまで結果を推測しないことです。業務上一括が必要なら、「成功時にまとめてコミット、失敗時に全体を取り消す」を、一つの検討可能な制御フローに書きます。部分成功を許すなら、成功したレコードと失敗したレコードの一覧を結果の一部にします。

最初の問いに戻ると、既定の制約エラーは通常、現在の文を取り消し、先の書き込みを自動的にすべて取り消すわけではありません。元に戻せるかは、その書き込みが同じトランザクションに残っているか、失敗後にどう終了するかで決まります。二つの境界を明示して実際に確認する方が、「トランザクションを付けた」という一言より、最後のデータの状態をよく説明できます。

文書とリンクの確認日:2026 年 10 月 3 日。表と JSON は添付スクリプトの実行結果です。一時データは教材用で、実際の業務データベースにはアクセスしていません。