みなさんこんにちは!ヒロポンです!
夜間バッチが途中でコケて、朝出社したらデータが中途半端に更新されてた……業務SEなら一度は血の気が引いた経験、ないですか??
SQL Server で「途中で死んでもデータを壊さない」ためのド定番が TRY...CATCH です。ただ、書き方をひとつ間違えると 握ったつもりで握れてない という、一番タチの悪い状態になります。
今回は実務でよく見かける TRY...CATCH エラー処理の落とし穴を5つ。SQL Server 2019 の実機で確認した、コピペで動く T-SQL つきでまとめました。バッチの事故を減らすリファレンスとして使ってもらえればと思います。
💡
BEGIN TRAN/COMMIT/ROLLBACKそのものの基本は SQL Server のトランザクション入門 で解説してます。今回はその先の、エラーを握って安全に巻き戻す型に絞った応用編です。
この記事で押さえる5つの落とし穴
「なんとなく TRY...CATCH で囲んどけば安全」。そう思い込んでると、だいたい下の5つのどれかで足をすくわれます。どれもバッチの本番事故に直結するやつです。
- CATCH に入っても、自動ではロールバックされない
XACT_STATE()を見ずに ROLLBACK して失敗するTHROWで再スローせず、エラーを握りつぶすTRY...CATCHでも捕捉できないエラーがある- ネストしたトランザクションで ROLLBACK が全部巻き戻る
⏱ 対処目安サマリ
まずは全体像から。忙しい人は、この表だけ持って帰ってもらってもかまいません!

では1つずつ見ていきます。
1. CATCH に入っても、自動ではロールバックされない
一番多いのがこれ。CATCH ブロックに入っても、SQL Server は勝手にロールバックしてくれません。
SET XACT_ABORT が既定の OFF だと、エラーが起きて CATCH に飛んでも、トランザクションは開きっぱなしで残ります。ここで ROLLBACK を書き忘れると、途中までの更新が宙ぶらりん。
まずダメな例から。
BEGIN TRY
BEGIN TRAN;
UPDATE dbo.Orders SET Status = 1 WHERE OrderId = 100;
-- ここで 0 除算エラーが起きたとする
UPDATE dbo.Orders SET Amount = Amount / 0 WHERE OrderId = 100;
COMMIT TRAN;
END TRY
BEGIN CATCH
-- ROLLBACK を書いてない! トランザクションは開いたまま残る
PRINT 'エラーが起きました';
END CATCH
実行結果:

これ、1件目の UPDATE が中途半端に残るんですよね。CATCH で「エラーが起きました」と出して満足してると、裏でトランザクションが浮いたまま。これ、地味に怖くないですか??
正しくはこう書きます。CATCH で明示的に ROLLBACK。
BEGIN TRY
BEGIN TRAN;
UPDATE dbo.Orders SET Status = 1 WHERE OrderId = 100;
UPDATE dbo.Orders SET Amount = Amount / 0 WHERE OrderId = 100;
COMMIT TRAN;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0
ROLLBACK TRAN; -- 明示的に巻き戻す
PRINT 'ロールバックしました: ' + ERROR_MESSAGE();
END CATCH
もう1つの手が、バッチ先頭に SET XACT_ABORT ON を書くやり方。これを ON にしとくと、実行時エラーで 自動的にトランザクションを中断・ロールバック してくれます。
SET XACT_ABORT ON; -- これで実行時エラー時に自動ロールバック
BEGIN TRY
BEGIN TRAN;
UPDATE dbo.Orders SET Status = 1 WHERE OrderId = 100;
UPDATE dbo.Orders SET Amount = Amount / 0 WHERE OrderId = 100;
COMMIT TRAN;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0 ROLLBACK TRAN;
THROW;
END CATCH
一行教訓: CATCH は自動で巻き戻さない。明示 ROLLBACK か SET XACT_ABORT ON を添えておく。
2. XACT_STATE() を見ずに ROLLBACK して失敗する
じゃあ CATCH で毎回 ROLLBACK すればいいのか。そう単純でもないんですよね、これが。
ROLLBACK TRAN は、そもそもトランザクションが開いてない状態で呼ぶとエラー(3903: 対応する BEGIN TRANSACTION がありません)になります。逆に、コミット不能な状態(doomed)なのに気づかず COMMIT しにいくのも事故のもと。
そこで使うのが XACT_STATE() です。戻り値の意味はこうなってます。

-1 の doomed 状態。これが業務SEの一番ハマるやつです。デッドロックの犠牲者になった時なんかにこの状態になって、COMMIT も追加の処理も一切できず、ROLLBACK しか受け付けません。
なので CATCH では、XACT_STATE() が 0 以外なら ROLLBACK。これが安全な型になります。
BEGIN CATCH
-- 0 (トランザクション無し) 以外なら巻き戻す。-1 の doomed もこれで拾える
IF XACT_STATE() <> 0
ROLLBACK TRAN;
PRINT 'XACT_STATE = ' + CAST(XACT_STATE() AS varchar(2));
END CATCH
実行結果(XACT_STATE = -1 を実機で再現):

@@TRANCOUNT > 0 でも似たことはできます。ただ XACT_STATE() のほうが doomed (-1) を明示的に判定できる ぶん、バッチの堅牢性は上がる。この辺は SQL Server のトランザクション入門 の分離レベルの話ともつながってきます。
一行教訓: CATCH の ROLLBACK は XACT_STATE() <> 0 で守る。特に doomed の -1 を取りこぼさない。
3. THROW で再スローせず、エラーを握りつぶす
これは静かに効いてくる怖いやつ。CATCH でエラーを握ったのに、呼び出し元には何も伝えないパターン。
CATCH の中で ROLLBACK して PRINT 'エラー' だけ書いて終わると、バッチとしては正常終了扱いになります。ジョブスケジューラも「成功」と記録する。夜間バッチが半分こけてたのに誰も気づかない……という事故は、ここから生まれます。
これ、私も一度やらかしてます。CATCH でエラーをログに書くだけにして THROW を入れてなくて、夜間バッチが途中でこけてたのに「成功」で終わってたんですよね。翌朝、集計が合わないと連絡が来て初めて気づいた、という。原因を追って半日、データを手で直すのにもう半日。それ以来、CATCH の締めは THROW、を体に叩き込みました。握りつぶしていいのは、本当に意図した時だけです。
握ったエラーを呼び出し元に投げ直すには、THROW を引数なしで書きます。
BEGIN TRY
BEGIN TRAN;
INSERT INTO dbo.Orders (OrderId, Amount) VALUES (100, 5000);
INSERT INTO dbo.Orders (OrderId, Amount) VALUES (100, 6000); -- 主キー重複でエラー
COMMIT TRAN;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRAN;
THROW; -- 捕まえた元のエラーを、そのまま呼び出し元へ再スロー
END CATCH
実行結果:

THROW を引数なしで呼ぶと、ERROR_NUMBER() や ERROR_MESSAGE() の情報を保ったまま、元のエラーを再現してくれます。これで呼び出し側の C# や PowerShell が例外として受け取れる。いい感じに失敗が伝わるようになります。
昔の RAISERROR でも再スローっぽいことはできます。ただエラー番号が 50000 に化けたりして、情報が変わっちゃう。元のエラーをそのまま伝えたいなら THROW です(SQL Server 2012 以降)。
ちなみに、握りつぶしていいのは「エラーが起きても続行したい」と意図して決めた時だけ。デフォルトは再スロー。これが安全側の設計です。
一行教訓: CATCH の締めは THROW。握りつぶしは意図がある時だけ。
4. TRY…CATCH でも捕捉できないエラーがある
TRY...CATCH は万能じゃないです。そもそも CATCH に飛んでこないエラー が、いくつかあります。
代表的なのはこの3つ。
- 重大度20以上のエラー — 接続そのものを切るレベルのやつ。CATCH に来る前にセッションが死ぬ
- コンパイルエラー — 存在しないテーブルや列を参照した時。バッチが実行前に弾かれる
- オブジェクト名解決の遅延エラー — 実行時に初めて解決される名前ミスの一部
たとえば存在しないテーブルを TRY の中で参照しても、同じバッチ内だと CATCH で拾えないことがあります。
BEGIN TRY
SELECT * FROM dbo.存在しないテーブル; -- コンパイル/名前解決エラー
END TRY
BEGIN CATCH
PRINT 'ここには来ないことがある'; -- 同一バッチのコンパイルエラーは素通り
END CATCH
実行結果:

こういうのは CATCH で拾おうとするより、設計とテストの段階で潰すのが正解です。ん? じゃあ何のための TRY…CATCH なのかというと、あくまで「実行時に起きうる想定内のエラー」を握るための仕組み、という整理ですね。オブジェクトの存在確認は OBJECT_ID() で事前にやる、みたいな別の守り方をします。
一行教訓: TRY…CATCH は実行時エラー用。コンパイルエラーと重大度20+は設計で潰す。
5. ネストしたトランザクションで ROLLBACK が全部巻き戻る
最後は、ストアドプロシージャを入れ子で呼ぶ時にやられるやつ。
SQL Server のトランザクションは、BEGIN TRAN を2回書いても 本当のネストにはなりません。@@TRANCOUNT が増えるだけで、中身は1つのトランザクション。そして ROLLBACK TRAN は、何回ネストしてても最外まで一気に巻き戻します。
BEGIN TRAN; -- @@TRANCOUNT = 1
INSERT INTO dbo.Log (Msg) VALUES ('親の処理');
BEGIN TRAN; -- @@TRANCOUNT = 2 (ネストしたつもり)
INSERT INTO dbo.Log (Msg) VALUES ('子の処理');
ROLLBACK TRAN; -- ← ここで親も子も全部巻き戻る! @@TRANCOUNT = 0
COMMIT TRAN; -- 3902 エラー: 対応する BEGIN TRANSACTION がありません
実行結果:

子だけロールバックしたつもりが、親の「親の処理」まで消える。おまけに最後の COMMIT が 3902 エラーになる。これ、内側のストアドが勝手に ROLLBACK した時に起きがちで、呼び出した親側からすると「何もしてないのに全部消えた」という地獄になります。
部分的に巻き戻したいなら、ROLLBACK TRAN じゃなく セーブポイント (SAVE TRAN) を使います。
BEGIN TRAN;
INSERT INTO dbo.Log (Msg) VALUES ('親の処理');
SAVE TRAN child; -- セーブポイントを打つ
BEGIN TRY
INSERT INTO dbo.Log (Msg) VALUES ('子の処理');
-- 子で何かエラーが起きたら…
END TRY
BEGIN CATCH
ROLLBACK TRAN child; -- セーブポイントまでだけ巻き戻す。親は生きてる
END CATCH
COMMIT TRAN;
こう書くと「子の処理」だけ取り消して、「親の処理」は残せます。いい感じに部分ロールバックできる。
一行教訓: ネストで ROLLBACK は最外まで全巻き戻し。部分だけなら SAVE TRAN を使う。
まとめ: 安全な TRY…CATCH の型(チートシート)
5つの落とし穴を全部避けると、バッチのエラー処理はこの型に落ち着きます。テンプレとしてコピペして使ってもらえれば、だいたいの事故は防げます。
図にすると、こういう流れ。

SET XACT_ABORT ON; -- 実行時エラーで自動中断 (落とし穴1の保険)
BEGIN TRY
BEGIN TRAN;
-- ここに本来の処理を書く
UPDATE dbo.Orders SET Status = 1 WHERE OrderId = 100;
COMMIT TRAN;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 -- 0 以外なら巻き戻す (落とし穴2・doomed -1 も拾う)
ROLLBACK TRAN;
THROW; -- 元のエラーを呼び出し元へ再スロー (落とし穴3)
END CATCH
ポイントを並べると、こんな感じ。
- 先頭に
SET XACT_ABORT ON(保険をかける) - CATCH の ROLLBACK は
XACT_STATE() <> 0で守る - 締めは
THROWで再スロー(握りつぶさない) - ネストや部分ロールバックが必要なら
SAVE TRAN - コンパイルエラーは CATCH に頼らず設計で潰す
エラー処理を「型」に嵌めてしまえば、毎回悩まなくて済みます。夜間バッチの「朝起きたらデータ半壊」みたいな事故は、この型を徹底するだけでかなり減らせますよ!!
よくある質問
Q1. CATCH に入れば自動でロールバックされないんですか?
されません。SET XACT_ABORT が既定の OFF のままだと、CATCH に飛んでもトランザクションは開いたまま残ります。CATCH 内で明示的に ROLLBACK TRAN を書くか、バッチ先頭で SET XACT_ABORT ON にしてください。どちらか一方ではなく、両方入れておくのが実務では安全です。
Q2. XACT_STATE() の -1 って何ですか?
トランザクションは有効だけど、コミットできない状態(doomed) を意味します。デッドロックの犠牲者になった時などにこの状態になり、COMMIT も追加の DML も一切受け付けず、ROLLBACK しかできません。CATCH では XACT_STATE() <> 0 で判定すれば、この -1 も 1 もまとめて安全に巻き戻せます。
Q3. THROW と RAISERROR はどっちを使えばいいですか?
再スロー(捕まえた元のエラーをそのまま投げ直す)なら THROW です。引数なしの THROW; は ERROR_NUMBER() などの情報を保ったまま再現してくれます。RAISERROR はエラー番号が 50000 に化けるなど情報が変わるので、独自のメッセージを出したい特殊なケース以外は THROW(SQL Server 2012 以降)で十分です。
Q4. ストアドの中で ROLLBACK したら呼び出し元が 3902/3903 エラーになりました。
ネストしたトランザクションで内側が ROLLBACK TRAN すると、@@TRANCOUNT が一気に 0 まで落ちて、外側の COMMIT が「対応する BEGIN がない」(3902)で失敗します。内側で全部巻き戻したくないなら、SAVE TRAN でセーブポイントを打って ROLLBACK TRAN セーブポイント名 で部分的に戻すか、内側ではロールバックせず THROW で外側に判断を委ねる設計にします。
次に読むべき記事





以上!
同じように夜間バッチのエラー処理で泣いた人いたら、どんどんシェア待ってるぜ!!
執筆者
バイブス父さん — 業務 SE 7 年 (SIer 正社員 2 / フリーランス 5)。 現職は SEO 直轄部の AI アドバイザー兼 PL、 副業で中小 SIer の CTO。 SIer の正社員からフリーランスに転じ、 複数のエージェント経由で案件を回してきた経験ベースで「業務 SE 視点」 の技術 + キャリア記事を書いています。
🐦 X: @hiro_progra0524 (日々の現場メモ更新中)
📝 About Me で経歴詳細を見る


