BigQuery ハンズオン教科書|2026-08 版
第 9 章

DML・タイムトラベル・スナップショット

更新・削除・MERGE を正しく使い、消したデータを 7 日以内に取り戻す

この章で学ぶこと

前提: 課金プロジェクト必須(概算 1 円未満。数 KB 規模のテーブルのみを扱うため、オンデマンドの無料枠「月 1 TiB のクエリ処理まで無料」に確実に収まります)。サンドボックスでは DML 文自体が実行できないため、この章だけは課金を有効化したプロジェクトが必要です。CTAS や基本的な SELECT 文は 第 4 章 を前提とします。

DML とバルク変更のコスト

BigQuery の DML(データ操作言語)は INSERT / UPDATE / DELETE / MERGE / TRUNCATE TABLE の 5 種類です。いずれも GA(一般提供)で、GoogleSQL 方言で書きます。TRUNCATE TABLE は全行を削除しつつスキーマ・説明・ラベルなどのメタデータは保持するので、全件削除だけが目的なら DELETE より効率的です。

文用途課金の考え方
INSERT新規行の追加スキャン対象(SELECT 部分やリテラル評価分)に課金
UPDATE既存行の更新(FROM 句で他テーブルと結合可)WHERE 条件・結合対象のスキャン分に課金
DELETE条件に合う行の削除(WHERE 必須)WHERE 条件のスキャン分に課金
MERGEINSERT・UPDATE・DELETE をアトミックに統合ソース・ターゲット双方のスキャン分に課金
TRUNCATE TABLE全行削除(メタデータは保持)他 DML よりコストが小さいことが多い

DML はいずれもスキャンしたバイト数に応じて課金される点で SELECT クエリと同じです。パーティション化されたテーブルに対する UPDATE / DELETE は、パーティションキーを WHERE 句に含めれば対象パーティションだけを書き換えるので、スキャン量を大きく抑えられます(パーティショニングの詳細は 第 6 章)。

同時実行数にも文の種類で違いがあります。INSERT は 24 時間で先着 1,500 ステートメントまで即座に実行され、それ以降は同時 10・キュー最大 100 まで受け付けます。一方 UPDATE / DELETE / MERGE は「ミューテーティング DML」として扱われ、同一テーブルに対して同時実行できるのは最大 2 件、キューは最大 20 件までです(2026-08 時点)。

INSERT 24 時間で先着 1500 件は即実行 以降は同時 10・キュー 100 まで UPDATE / DELETE / MERGE 同時実行は最大 2 まで キューは最大 20 まで 対象テーブル 書込み 書込み
図 9-1 DML 種別ごとの同時実行数の違い。INSERT は緩やかだが、UPDATE・DELETE・MERGE は同時 2 件までしか走らない。
実務の視点: DML は「大量一括変更」向けであり、秒間何十件も発生する行単位の少量更新(OLTP 的な要件)には向きません。ミューテーティング DML の同時実行上限が 2 件しかないため、そうした要件を DML で無理に実現しようとするとキュー詰まりが起きます。高頻度の細かい書き込みが必要なら、ストリーミング取り込み + 定期的な MERGE によるバッチ反映や、Cloud SQL などの OLTP データベースとの併用を検討してください。

MERGE による Upsert

MERGE はソーステーブルとターゲットテーブルを突き合わせ、行ごとに次の 3 パターンへ振り分けて 1 つのアトミックな操作として実行します。CDC(変更データキャプチャ)を反映する際の基本形です。

MERGE [INTO] target_name [[AS] alias]
USING source_name [[AS] alias]
ON merge_condition
WHEN MATCHED [AND search_condition] THEN
  UPDATE SET ... | DELETE
WHEN NOT MATCHED [BY TARGET] [AND search_condition] THEN
  INSERT (...) VALUES (...)
WHEN NOT MATCHED BY SOURCE [AND search_condition] THEN
  UPDATE SET ... | DELETE
1 行ごとに次のいずれかに分類 ソースのみ 両方に存在 ターゲットのみ 実行 実行 実行 INSERT UPDATE (または DELETE) DELETE (省略可)
図 9-2 MERGE は 1 行ごとにソース/ターゲットの一致状況を判定し、INSERT・UPDATE・DELETE のいずれかに振り分ける。

WHEN NOT MATCHED BY TARGET(ソースにしかない行)では列名を省略すると、ターゲットテーブルの全列が序数の昇順で対象になります。ただし取り込み時刻パーティションテーブルが対象の場合は列名の明示が必須です。重要な制約として、WHEN MATCHED 句で UPDATE を行う場合、複数のソース行が同一のターゲット行にマッチしているとランタイムエラーになります。ステージングテーブル側のキー重複が典型的な原因です。

マルチステートメントトランザクションとの関係

BEGIN TRANSACTION / COMMIT TRANSACTION / ROLLBACK TRANSACTION を使うと、複数の DML をまとめてアトミックに実行できます(スナップショット分離)。トランザクション内では CURRENT_TIMESTAMP() はトランザクション開始時刻を返し、変更できるテーブルは最大 100、パーティション変更は最大 100,000 までという上限があります。永続オブジェクトを作成・削除する DDL はトランザクション内では使えません。単一クエリで完結させる場合はこの構文で十分ですが、複数クエリにまたがる一連の処理は次章以降で扱うスクリプトやセッションで組み立てます。

タイムトラベルと fail-safe

タイムトラベルは、テーブルの過去の状態を FOR SYSTEM_TIME AS OF 句で読み出せる仕組みです。既定の保持期間は 7 日間で、データセット単位(または ALTER SCHEMA によるプロジェクト既定値)で 2 日から 7 日の範囲に設定できます(2026-08 時点)。

-- タイムトラベル保持期間を変更する(48〜168 の 24 の倍数で指定)
ALTER SCHEMA `PROJECT_ID.handson_ch09`
SET OPTIONS (max_time_travel_hours = 48);

-- 過去の状態を読み出す
SELECT *
FROM `PROJECT_ID.handson_ch09.orders`
FOR SYSTEM_TIME AS OF TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 10 MINUTE);

タイムトラベル期間が終了すると、続けて fail-safe 期間が始まります。fail-safe は削除・変更されたデータをさらに 7 日間、BigQuery 内部に保持する仕組みですが、この 7 日間は固定でユーザーが設定を変更することはできず、FOR SYSTEM_TIME AS OF でユーザーが直接読み出すこともできません(データ復旧には Google Cloud のサポートが必要です)。物理ストレージ課金モデルではタイムトラベルと fail-safe 用のストレージにも課金されますが、論理ストレージモデルでは基本料金に含まれます。

fail-safe 追加 7 日・固定 タイムトラベル窓 既定 7 日・2〜7 日で設定可 現在 FOR SYSTEM_TIME AS OF
図 9-3 タイムトラベル→fail-safe の時間軸。ユーザーが読み書きできるのはタイムトラベル窓の中だけで、fail-safe はサポート経由の最後の砦。
実務の視点: タイムトラベル保持期間を短く設定するとストレージコストを抑えられますが、その分だけ誤操作から復旧できる猶予も削れます。fail-safe は通常運用の復旧手段ではなく、タイムトラベルが尽きたあとにサポート経由でしか触れない最終手段です。「気づいてから何日以内に必ず復旧できるか」を基準にタイムトラベル窓の長さを決めてください。

スナップショット・クローン・変更履歴

テーブルスナップショットとテーブルクローンは、どちらも「ある時点のテーブルのコピー」を作る機能ですが、書き込み可否と課金モデルが異なります。

観点テーブルスナップショットテーブルクローン
書き込み読み取り専用書き込み可能(標準テーブルと同様に操作可)
課金差分課金。基本テーブルと異なるバイト分のみ課金され、複数スナップショットがある場合は最古のものがユニークなデータ分の費用を負担作成時点ではストレージ費用ゼロ。変更・追加した差分のみ課金
用途特定時点の凍結コピー(バックアップ)書き換えて試せる複製(開発・検証環境)
制約基本テーブルと同一リージョン・同一組織のみ、ビュー不可同上。カラム型ストレージのため小さな変更でもストレージ増加が大きくなりうる
-- スナップショットを作る(過去の時点を指定することも可能)
CREATE SNAPSHOT TABLE `PROJECT_ID.handson_ch09.prices_snapshot`
CLONE `PROJECT_ID.handson_ch09.prices`
OPTIONS (expiration_timestamp = TIMESTAMP_ADD(CURRENT_TIMESTAMP(), INTERVAL 1 DAY));

-- クローンを作る
CREATE TABLE `PROJECT_ID.handson_ch09.prices_clone`
CLONE `PROJECT_ID.handson_ch09.prices`;

-- スナップショットから標準テーブルへ復元する(上書きする場合は CREATE OR REPLACE)
CREATE OR REPLACE TABLE `PROJECT_ID.handson_ch09.prices`
CLONE `PROJECT_ID.handson_ch09.prices_snapshot`;
ベーステーブル 作成時点を保存 作成時点をコピー テーブルスナップショット 読み取り専用 差分課金(最古のみ負担) ベースが変わっても値は固定 テーブルクローン 書き込み可能 作成時ストレージ 0 円 ベースと独立して書き込み可
図 9-4 スナップショットは凍結された読み取り専用コピー、クローンは書き込み可能で独立して分岐するコピー。

変更履歴を追う(APPENDS / CHANGES)

APPENDS(TABLE table, start_timestamp, end_timestamp) は追加された行だけを、CHANGES(TABLE table, start_timestamp, end_timestamp) は追加・更新・削除のすべてを返すテーブル値関数です。APPENDS は常に使えますが、CHANGES を使うにはテーブル作成時(または後から)に enable_change_history = TRUE を設定する必要があり、この設定はメタデータ保存コストの増加を伴います。どちらも戻り値に _CHANGE_TYPE(INSERT / UPDATE / DELETE)と _CHANGE_TIMESTAMP 列が付与され、CHANGES はさらに更新由来の削除かどうかを示す _CHANGE_IS_FOR_UPDATE 列を持ちます。なお CHANGES は 1 回の呼び出しで指定できる期間(start_timestamp〜end_timestamp)が最大 1 日という上限があるため、長い期間を追うには呼び出しを日単位に分割します。

CREATE TABLE `PROJECT_ID.handson_ch09.events` (event_id INT64, status STRING)
OPTIONS (enable_change_history = true);

SELECT event_id, status, _CHANGE_TYPE, _CHANGE_TIMESTAMP
FROM CHANGES(TABLE `PROJECT_ID.handson_ch09.events`, NULL, CURRENT_TIMESTAMP())
ORDER BY _CHANGE_TIMESTAMP;
ハンズオン 9-1|小さいテーブルで DELETE の事故を起こし、タイムトラベルで復旧する

演習用データセット handson_ch09(ロケーション US)と 5 行だけのテーブルを作り、条件を書き間違えた DELETE の事故を FOR SYSTEM_TIME AS OF で復旧します。課金プロジェクト必須(数 KB 規模・オンデマンド無料枠内)。所要時間の目安は 10 分です。

  1. データセットとテーブルを作る。PROJECT_ID は自分のプロジェクト ID に置き換えてください。
# bq CLI でデータセットを作成
bq mk --location=US --dataset PROJECT_ID:handson_ch09
CREATE TABLE `PROJECT_ID.handson_ch09.orders` AS
SELECT * FROM UNNEST([
  STRUCT(1 AS order_id, 'JP' AS region, 120 AS amount),
  STRUCT(2, 'JP', 340),
  STRUCT(3, 'US', 500),
  STRUCT(4, 'JP', 90),
  STRUCT(5, 'US', 220)
]);
  1. 「order_id = 4 のテスト行だけ消すつもりが、region で書いてしまった」という典型的な事故を再現する。
-- 本来 WHERE order_id = 4 のつもりが、region で条件を書いてしまった
DELETE FROM `PROJECT_ID.handson_ch09.orders`
WHERE region = 'JP';

SELECT COUNT(*) FROM `PROJECT_ID.handson_ch09.orders` を実行すると 5 行のはずが 2 行に減っています(JP の 3 行が消えた)。

  1. 削除前の状態をタイムトラベルで確認する。削除を実行してからの経過時間に合わせて INTERVAL の分数を調整してください。
SELECT *
FROM `PROJECT_ID.handson_ch09.orders`
FOR SYSTEM_TIME AS OF TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 5 MINUTE)
ORDER BY order_id;

5 行すべて(削除された JP の 3 行を含む)が返れば、この時点はまだ削除前の状態です。

  1. 消えた行だけを復旧する。
INSERT INTO `PROJECT_ID.handson_ch09.orders`
SELECT *
FROM `PROJECT_ID.handson_ch09.orders`
FOR SYSTEM_TIME AS OF TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 5 MINUTE)
WHERE region = 'JP';

再度 COUNT(*) を実行し、5 行に戻っていることを確認します。

誤操作からの復旧はこの順で進める ① DELETE 誤って全件削除 ② FOR SYSTEM_TIME AS OF で過去を確認 ③ 正しい行を特定 差分を洗い出す ④ INSERT で復旧
図 9-5 誤削除からの復旧フロー。気づいたら即座にタイムトラベルで過去の状態を確認し、差分だけを INSERT で戻す。

後片付け:

bq rm -f -t PROJECT_ID:handson_ch09.orders
ハンズオン 9-2|MERGE で在庫データを Upsert する

ターゲットテーブルとステージングテーブルを作り、MERGE で「既存 SKU は更新・新規 SKU は追加」する CDC の基本形を体験します。課金プロジェクト必須(数 KB 規模)。所要時間の目安は 8 分です。

  1. ターゲットとステージングのテーブルを作る。
CREATE TABLE `PROJECT_ID.handson_ch09.inventory` AS
SELECT * FROM UNNEST([
  STRUCT('sku-001' AS sku, 100 AS stock, 'active' AS status),
  STRUCT('sku-002', 50, 'active'),
  STRUCT('sku-003', 0, 'discontinued')
]);

CREATE TABLE `PROJECT_ID.handson_ch09.inventory_staging` AS
SELECT * FROM UNNEST([
  STRUCT('sku-002' AS sku, 30 AS stock, 'active' AS status),
  STRUCT('sku-004', 75, 'active')
]);
  1. MERGE で反映する。sku-002 は在庫更新、sku-004 は新規追加になります。
MERGE `PROJECT_ID.handson_ch09.inventory` T
USING `PROJECT_ID.handson_ch09.inventory_staging` S
ON T.sku = S.sku
WHEN MATCHED THEN
  UPDATE SET stock = S.stock, status = S.status
WHEN NOT MATCHED BY TARGET THEN
  INSERT (sku, stock, status) VALUES (S.sku, S.stock, S.status);

SELECT * FROM `PROJECT_ID.handson_ch09.inventory` ORDER BY sku の結果は次のようになります。

skustockstatus
sku-001100active(変更なし)
sku-00230active(UPDATE で更新)
sku-0030discontinued(変更なし)
sku-00475active(INSERT で新規追加)

後片付け:

bq rm -f -t PROJECT_ID:handson_ch09.inventory
bq rm -f -t PROJECT_ID:handson_ch09.inventory_staging
ハンズオン 9-3|スナップショットとクローンの違いを実測する

小さい価格テーブルからスナップショットとクローンを作り、ベーステーブルへの一括 UPDATE がそれぞれにどう影響するかを確認します。課金プロジェクト必須(数 KB 規模)。所要時間の目安は 10 分です。

  1. ベーステーブルを作り、リスクのある一括更新をする前にスナップショットとクローンを両方作っておく。
CREATE TABLE `PROJECT_ID.handson_ch09.prices` AS
SELECT * FROM UNNEST([
  STRUCT('A' AS item, 1000 AS price),
  STRUCT('B', 2000)
]);

CREATE SNAPSHOT TABLE `PROJECT_ID.handson_ch09.prices_snapshot`
CLONE `PROJECT_ID.handson_ch09.prices`
OPTIONS (expiration_timestamp = TIMESTAMP_ADD(CURRENT_TIMESTAMP(), INTERVAL 1 DAY));

CREATE TABLE `PROJECT_ID.handson_ch09.prices_clone`
CLONE `PROJECT_ID.handson_ch09.prices`;
  1. 「全商品の価格を 2 倍にするつもりが、条件を外してしまった」事故を再現する。
UPDATE `PROJECT_ID.handson_ch09.prices`
SET price = price * 2
WHERE TRUE;

ベーステーブルは A=2000, B=4000 になりますが、prices_snapshot は作成時点の A=1000, B=2000 のまま固定されています(読み取り専用)。prices_clone も同じく A=1000, B=2000 のままです(ベースとは独立しているため、ベース側の変更は伝播しません)。

  1. クローンは書き込み可能なので、クローン側だけ個別に更新できることを確認する。
UPDATE `PROJECT_ID.handson_ch09.prices_clone`
SET price = price + 100
WHERE item = 'A';

クローンは A=1100 になりますが、ベーステーブルの A=2000 には一切影響しません。

  1. スナップショットから復元して、誤った一括更新をなかったことにする。
CREATE OR REPLACE TABLE `PROJECT_ID.handson_ch09.prices`
CLONE `PROJECT_ID.handson_ch09.prices_snapshot`;

ベーステーブルが A=1000, B=2000 に戻ります。この章のハンズオンで作ったリソースは以上のため、データセットごと削除します。

後片付け:

bq rm -r -f -d PROJECT_ID:handson_ch09

つまずきポイント

dry run せずに本番相当のテーブルへ DML を流す: WHERE 句の条件を書き間違えると、この章のハンズオンのように意図しない範囲が変更・削除されます。本番前提のテーブルでは必ず dry run(第 7 章)で影響範囲を見積もり、小さいデータで一度試してから流してください。
タイムトラベル窓を短くしすぎる/fail-safe を復旧手段だと誤解する: max_time_travel_hours を最小の 48(2 日)に設定するとストレージコストは下がりますが、事故に気づくのが 3 日後だと FOR SYSTEM_TIME AS OF では手が届きません。fail-safe はユーザーがクエリで読める領域ではなく、サポートに依頼する最終手段です。
MERGE のステージング側にキー重複がある: WHEN MATCHED 句で UPDATE を行う MERGE は、ソース側に同じキーの行が複数あるとランタイムエラーになります。ステージングテーブルを作る ETL 側で主キーの重複がないことを事前に保証してください。
ストリーミング直後のデータや変更履歴関数の制約: Storage Write API(REST)で書き込んだ直近のデータには変更系 DML を実行できません(gRPC 版で書き込んだ行なら直近 30 分以内でも DML がサポートされます。詳細は第 12 章)。また APPENDS と CHANGES はどちらもマルチステートメントトランザクションの中からは呼び出せません。

理解度チェック

Q1. タイムトラベルと fail-safe の違いは?

タイムトラベルは既定 7 日(2〜7 日で設定可能)の窓で、FOR SYSTEM_TIME AS OF によりユーザーが自分で過去の状態を読み出せます。fail-safe はタイムトラベル終了後にさらに 7 日間(固定・変更不可)データを内部保持する仕組みで、ユーザーが直接クエリで読み出すことはできず、復旧にはサポートへの問い合わせが必要です。

Q2. テーブルスナップショットとテーブルクローンの主な違いは?

スナップショットは読み取り専用で、差分課金(最古のスナップショットがユニークなデータ分のストレージ費用を負担)です。クローンは書き込み可能で、作成時点ではストレージ費用がゼロ、その後の変更・追加分だけが課金されます。

Q3. MERGE 文でランタイムエラーになる典型的な原因は?

WHEN MATCHED 句で UPDATE(または DELETE)を行う場合に、複数のソース行が同一のターゲット行にマッチしていることです。ステージングテーブル側のキー重複が典型的な原因です。

Q4.(シナリオ)夜間バッチの UPDATE を間違え、対象テーブルの大量の行の status 列を誤った値で上書きしてしまった。データセットのタイムトラベル保持期間は既定の 7 日のままで、事故に気づいたのは実行の 10 分後である。今すぐ取るべき行動は?
A. fail-safe に直接クエリを投げて復旧する B. FOR SYSTEM_TIME AS OF で誤った UPDATE 前の状態を読み出し、正しい値に戻す C. スナップショットを事前に作っていないので復旧は不可能とあきらめる D. Google Cloud サポートにチケットを起票し、7 日間の応答を待つ

B。 事故の 10 分後ならタイムトラベル窓(7 日間)の中なので、FOR SYSTEM_TIME AS OF で UPDATE 前の状態を読み出し、正しい値を作り直せます。A は誤り: fail-safe はユーザーが直接クエリで読める領域ではありません。C は誤り: スナップショットがなくてもタイムトラベルだけで復旧できます。D は誤り: タイムトラベル窓内で自分で即座に復旧できるのに、7 日待つ必要はありません。

Q5.(シナリオ)本番データセットの検証環境を素早く複製し、複製後は検証環境側だけで自由に UPDATE・DELETE を試したい。複製直後のストレージ費用は極力抑えたい場合、最も適した機能はどれか?
A. テーブルスナップショット B. テーブルクローン C. CREATE TABLE AS SELECT による物理コピー D. bq cp によるテーブルコピージョブ

B。 テーブルクローンは書き込み可能で、作成時点ではストレージ費用がかからず、変更した差分だけに課金されるため、要件(書き込み可能・低コストな複製)に合致します。A は誤り: スナップショットは読み取り専用なので UPDATE・DELETE ができません。C・D は誤り: どちらも複製した瞬間から全データ分のストレージ費用が発生する物理コピーです。

まとめと次章へ

次章では、BigLake・Iceberg やフェデレーションを使って BigQuery の外側にあるデータを扱う方法を学びます(第 10 章)。