Cloudflare ハンズオン教科書|2026-08 版
第 7 章

D1 — サーバーレス SQL データベース

SQLite ベースの D1 でリレーショナルな API を建てる

この章で学ぶこと

D1 の位置づけ — SQLite セマンティクスのサーバーレス SQL

D1 は Cloudflare のマネージドなサーバーレス SQL データベースです。クエリエンジンには SQLite を採用しており、CREATE TABLE や JOIN、外部キー制約といった標準的な SQL に加え、FTS5(全文検索)・JSON 関数・数学関数などの SQLite 拡張が使えます。Free プランでも利用でき、Worker からはバインディング経由で直接クエリを発行します。第 5 章の KV が「結果整合性の key-value」だったのに対し、D1 はリレーショナルなスキーマとインデックスを持つ「本物の SQL データベース」です。

アーキテクチャ上の重要な特性が 2 つあります。

リードレプリカと Sessions API

既定では読み書きの全クエリが世界のどこか 1 箇所にあるプライマリインスタンスへ届くため、遠いユーザーほどレイテンシが伸びます。グローバルリードレプリカを有効にすると(ダッシュボードの Settings から)、D1 は対応全リージョン(ENAM / WNAM / WEUR / EEUR / APAC / OC。2026-08 時点)に読み取り専用のコピーを自動作成し、読み取りクエリを近いレプリカへ振り分けます。

インスタンス種別書き込みクエリ読み取りクエリ
プライマリ処理できる処理できる
リードレプリカプライマリへ転送する自身のコピーで処理できる

レプリカへの複製は非同期のため、レプリカは常に少し古い可能性があります(レプリカラグ)。この一貫性問題を解決するのが Sessions API です。env.DB.withSession() でセッションを開始すると、セッション内の全クエリに逐次一貫性(sequential consistency)が保証されます。逆に言えば、Sessions API を使わない限りリードレプリカは使われず、全クエリがプライマリで実行されます。開始パラメータは "first-primary"(最初のクエリを必ずプライマリへ)、"first-unconstrained"(既定。どのインスタンスでもよい)、または前回セッションの bookmark 文字列の 3 通りです。

withSession() あり — 読み取りは近いレプリカで完結する Worker (APAC) リードレプリカ (APAC) プライマリ (WEUR) 全クエリ 読み取りはここで完結 書き込みは転送 非同期で複製(ラグあり) withSession() なし(既定)— 全クエリが遠いプライマリへ直行 Worker (APAC) プライマリ (WEUR) 読み取りも書き込みもプライマリで実行
図 7-1 上下の違いは「レプリカという中継点があるかどうか」だけです。レプリカを経由できるのは withSession() を通したクエリに限られ、書き込みは経路に関わらずプライマリへ回されます。破線の非同期複製がレプリカラグの正体で、Sessions API はこのラグを跨いでも読み順が壊れないようにする仕掛けです。

もう 1 つ運用面で心強いのが Time Travel です。常時有効・追加コストなしで、過去 30 日(Free プランは 7 日。2026-08 時点)の任意の 1 分単位の状態へ npx wrangler d1 time-travel restore で復元できます。WHERE 句を忘れた DELETE をやらかしても戻れます。

Worker Binding API — prepare / bind / run / batch

Worker から D1 を使うには、wrangler.jsonc に d1_databases バインディングを定義します。binding に指定した名前が env オブジェクトのプロパティ名になります。

{
  "d1_databases": [
    {
      "binding": "DB", // Worker からは env.DB でアクセス
      "database_name": "bookshelf-db",
      "database_id": "<YOUR_DATABASE_ID>" // wrangler d1 create の出力の ID に置き換える
    }
  ]
}

クエリは「①prepare() で SQL 文を準備 → ②bind() でプレースホルダ ? に値を束縛 → ③実行メソッドで結果を受け取る」の 3 段階で発行します。値を SQL 文字列に直接埋め込まず bind() を通すことで、SQL インジェクションを構造的に防げます。実行メソッドは用途別に 4 つあります。

メソッド返り値用途
run() / all()D1Result(results 配列 + meta)基本形。all() は run() の別名
first()先頭行のオブジェクト。行がなければ null1 件取得。列名を渡すとその値だけ返す
raw()配列の配列(メタデータなし)大量行を軽量に受け取る
batch()D1Result の配列複数文を 1 往復で実行(後述)

D1Result の meta には rows_read(スキャンした行数)、rows_written、last_row_id(直近の INSERT の ROWID)、changes(変更された行数)、どのインスタンスが処理したかを示す served_by_region / served_by_primary(リモート実行では常に返り、ローカル開発では undefined)などが入ります。料金は rows read / written に基づくため、meta はコストの実測装置でもあります。

batch() は複数の prepared statement を 1 回のネットワーク往復でまとめて実行します。各文は順番に・並行せずに実行され、全体が SQL トランザクションとして扱われます。途中の文が失敗するとその文のエラーが返り、シーケンス全体がロールバックされます。D1 は明示的な BEGIN TRANSACTION を書かない自動コミット動作のため、複数文の原子性が必要な場面では batch() が第一の選択肢です。

個別に await(往復 3 回) Worker D1 ① 適用済み ② 適用済み ③ 失敗 ① INSERT ② INSERT ③ INSERT(失敗) ①② は残り、③ だけ失敗(部分適用) batch()(往復 1 回・原子的) Worker D1 トランザクション ①②③ 失敗なら全て取消 batch([①②③]) ③ が失敗すれば ①② も取り消される
図 7-2 減るのは往復回数だけではありません。左は 3 文が別々の自動コミットなので、3 回目が落ちても ①② は DB に残ります。右は 3 文が 1 つのトランザクションに包まれるため、どこで失敗しても DB は実行前の状態に戻ります。
// 単発クエリ: prepare → bind → run(TypeScript は型パラメータで行の型を付けられる)
const { results, meta } = await env.DB
  .prepare("SELECT * FROM books WHERE author = ?")
  .bind("夏目漱石")
  .run<BookRow>();
console.log(meta.rows_read); // このクエリがスキャンした行数

// batch: 同じ prepared statement を使い回して 1 往復で実行(暗黙のトランザクション)
const stmt = env.DB.prepare("INSERT INTO books (title, author) VALUES (?, ?)");
const batchResults = await env.DB.batch([
  stmt.bind("こころ", "夏目漱石"),
  stmt.bind("羅生門", "芥川龍之介"),
]);

このほかにパラメータ束縛なしで文字列をそのまま実行する exec() もありますが、prepared statement より安全性・性能とも劣るため、公式ドキュメントもメンテナンスや一回限りの作業専用としています。アプリケーションコードでは使いません。

migrations とローカル開発・本番の関係

スキーマ変更をダッシュボードから手で流すと、環境間の差分がすぐ追えなくなります。D1 には Wrangler 組み込みのマイグレーション機構があり、migrations/ フォルダに連番付き .sql ファイルとしてスキーマ変更を積み上げます。適用済みのマイグレーションはデータベース内の d1_migrations テーブルに記録されるため、apply は未適用分だけを順に実行します。適用中にエラーが起きるとそのマイグレーションはロールバックされ、直前までの適用状態が保たれます。

そして D1 のローカル開発は「本番の複製に触る」のではなく、完全に別物のローカル DB を使います。wrangler dev は既定でローカルモードで起動し、D1 の実体は .wrangler/state/v3/d1 配下の SQLite ファイルです(Wrangler v3 以降は実行をまたいで永続化されます)。d1 execute や migrations apply には --local / --remote フラグがあり、向き先を明示できます。開発フローは次の順序が基本です。

  1. migrations apply <DB名> --local でローカル DB にスキーマを適用する
  2. wrangler dev でローカル DB 相手に実装・動作確認する
  3. migrations apply <DB名> --remote で本番 DB にスキーマを適用する
  4. wrangler deploy で Worker をデプロイする
migrations/ フォルダ スキーマの唯一の正 --local で適用 --remote で適用 ローカル DB .wrangler/state/v3/d1 本番 DB(リモート) Cloudflare 上 同期しない クエリ クエリ wrangler dev ローカルの Worker wrangler deploy デプロイ済み Worker
図 7-3 migrations/ は 1 つでも、その適用先は独立した 2 つの DB です。左右の下段はそれぞれ自分の側の DB しか見ないため、色の付いた --remote の辺を忘れたままデプロイすると、本番 Worker は空の本番 DB を叩いて no such table になります。

本番データ相手にローカル開発したい場合は、バインディング設定に "remote": true を付ける remote bindings という仕組みもありますが、本番への変更は取り消せない点に注意してください。

制限と料金

主要な制限は次のとおりです(2026-08 時点)。

項目FreeWorkers Paid
データベース数 / アカウント1050,000(申請で増枠可)
1 データベースの最大サイズ500 MB10 GB(増枠不可)
アカウント合計ストレージ5 GB1 TB(申請で増枠可)
Worker 呼び出しあたりのクエリ数501,000
Time Travel の復元可能期間7 日30 日

プランに依存しない制限として、SQL 文の最大長 100 KB、1 クエリのバインド変数は最大 100 個、クエリの最大実行時間 30 秒、1 テーブル最大 100 列、行の最大サイズ 2 MB があります。batch() ではこれらの個別制限が内側の各文に適用され、さらに 30 秒制限はバッチ呼び出し全体にもかかります。

料金は「読んだ行数・書いた行数・ストレージ」の 3 軸だけで、待機コストはゼロ(scale-to-zero)、エグレス課金もありません(2026-08 時点)。

指標FreeWorkers Paid
Rows read500 万行 / 日月 250 億行込み + 100 万行あたり 0.001 USD
Rows written10 万行 / 日月 5,000 万行込み + 100 万行あたり 1.00 USD
ストレージ合計 5 GB5 GB 込み + 1 GB・月あたり 0.75 USD

インデックスで rows_read を減らす

ここで重要なのは、rows read が「返した行数」ではなく「スキャンした行数」で数えられることです。インデックスのない列を WHERE で絞ると、5,000 行のテーブルなら 1 行返すだけでも 5,000 行分の read が課金されます。頻繁に検索条件・JOIN 条件に使う列には CREATE INDEX を張りましょう(INTEGER PRIMARY KEY には自動で索引が効くため不要です)。インデックスが効いているかは EXPLAIN QUERY PLAN で確認できます。全表スキャンなら SCAN、インデックス利用なら SEARCH ... USING INDEX と表示されます。

EXPLAIN QUERY PLAN SELECT * FROM books WHERE author = '夏目漱石';
QUERY PLAN
`--SEARCH books USING INDEX idx_books_author (author=?)
SELECT * FROM books WHERE author = ? 索引なし 索引あり SCAN books 5,000 行すべて読む rows_read = 5,000 SEARCH books USING INDEX 3 行だけ読む 残りは触らない rows_read = 3
図 7-4 左右の枠は同じ 5,000 行の books テーブルで、塗った部分が課金対象の rows_read です。同じ SQL・同じ 1 件の結果でも、支払うのは塗った長さの方。EXPLAIN QUERY PLAN が SCAN と出たら左の全塗りを毎回買っていると読み替えてください(右の実比は 3/5,000 で、図よりさらに小さくなります)。

インデックス列への書き込みは本体 + インデックスで rows written が 1 行分増えますが、読み取り削減の利益がほぼ常に上回ります。複数列インデックスは「左端の列を含む検索」にだけ効く点((a, b) のインデックスは a 単独には効くが b 単独には効かない)、インデックス作成後に PRAGMA optimize を実行するとクエリプランナの統計が更新される点も覚えておきましょう。

ハンズオンの前提

第 1 章の環境(Cloudflare アカウント + Node.js + wrangler ログイン済み)があれば、この章のハンズオンは Free プランで完走できます。所要時間はあわせて 40〜60 分です。

ハンズオン 7-1|プロジェクトと D1 データベースを作成する

蔵書管理 API「bookshelf」の土台を作ります。Worker プロジェクトを作成し、D1 データベースを作ってバインドするまでで約 10 分です。

  1. Worker プロジェクトを作成する。テンプレートは Hello World example → Worker only → TypeScript を選ぶ
    npm create cloudflare@latest -- bookshelf-api
    cd bookshelf-api
  2. D1 データベースを作成する
    npx wrangler d1 create bookshelf-db
    ✅ Successfully created DB 'bookshelf-db' in region APAC
    Created your new D1 database.
    {
      "d1_databases": [
        {
          "binding": "bookshelf_db",
          "database_name": "bookshelf-db",
          "database_id": "<unique-ID-for-your-database>"
        }
      ]
    }
  3. 「Would you like Wrangler to add it on your behalf?」と聞かれたら Yes と答える。wrangler.jsonc に d1_databases ブロックが自動追記される
  4. 追記されたブロックの binding を "DB" に変更する(本章のコードは env.DB 前提)。database_id はそのまま使う

npx wrangler d1 list に bookshelf-db が表示されれば成功です。データベース名は後から変えられないため、用途と環境がわかる名前(例: staging-db-web)を付けるのが公式推奨です。

ハンズオン 7-2|migrations でスキーマを定義する

books テーブルとインデックスをマイグレーションとして作成し、まずローカル DB に適用します。約 10 分です。

  1. マイグレーションファイルを生成する
    npx wrangler d1 migrations create bookshelf-db create_books
    migrations/ フォルダに 0000_create_books.sql のような連番付きファイルが生成される
  2. 生成されたファイルに次のスキーマを書く
    -- migrations/0000_create_books.sql
    CREATE TABLE IF NOT EXISTS books (
      id INTEGER PRIMARY KEY,
      title TEXT NOT NULL,
      author TEXT NOT NULL,
      read INTEGER NOT NULL DEFAULT 0 -- 0: 未読, 1: 読了
    );
    CREATE INDEX IF NOT EXISTS idx_books_author ON books(author);
  3. ローカル DB に適用する。適用予定の一覧が表示されるので確認して進める
    npx wrangler d1 migrations apply bookshelf-db --local

npx wrangler d1 execute bookshelf-db --local --command="PRAGMA table_list" を実行し、books と適用記録用の d1_migrations テーブルが見えれば成功です。

ハンズオン 7-3|蔵書 CRUD API を実装する

一覧・取得・登録・更新・削除の 5 エンドポイントを実装し、ローカルで動作確認します。約 20 分です。

  1. src/index.ts を次の内容に置き換える(前半: 読み取りと登録)
    export interface Env {
      DB: D1Database;
    }
    
    type BookRow = { id: number; title: string; author: string; read: number };
    
    export default {
      async fetch(request, env): Promise<Response> {
        const url = new URL(request.url);
        const m = url.pathname.match(/^\/api\/books\/(\d+)$/);
    
        // 一覧: GET /api/books
        if (request.method === "GET" && url.pathname === "/api/books") {
          const { results } = await env.DB
            .prepare("SELECT * FROM books ORDER BY id")
            .run<BookRow>();
          return Response.json(results);
        }
        // 1 件取得: GET /api/books/:id
        if (request.method === "GET" && m) {
          const book = await env.DB
            .prepare("SELECT * FROM books WHERE id = ? LIMIT 1")
            .bind(Number(m[1]))
            .first<BookRow>();
          return book
            ? Response.json(book)
            : Response.json({ error: "not found" }, { status: 404 });
        }
        // 登録: POST /api/books
        if (request.method === "POST" && url.pathname === "/api/books") {
          const { title, author } = await request.json<{ title: string; author: string }>();
          const { meta } = await env.DB
            .prepare("INSERT INTO books (title, author) VALUES (?, ?)")
            .bind(title, author)
            .run();
          return Response.json({ id: meta.last_row_id }, { status: 201 });
        }
        return handleWrite(request, env, m); // 更新・削除は下の関数へ
      },
    } satisfies ExportedHandler<Env>;
  2. 続けて同じファイルの末尾に更新・削除を追加する
    async function handleWrite(
      request: Request,
      env: Env,
      m: RegExpMatchArray | null,
    ): Promise<Response> {
      // 更新: PUT /api/books/:id
      if (request.method === "PUT" && m) {
        const body = await request.json<{ title: string; author: string; read: boolean }>();
        const { meta } = await env.DB
          .prepare("UPDATE books SET title = ?, author = ?, read = ? WHERE id = ?")
          .bind(body.title, body.author, body.read ? 1 : 0, Number(m[1]))
          .run();
        return meta.changes === 0
          ? Response.json({ error: "not found" }, { status: 404 })
          : Response.json({ updated: meta.changes });
      }
      // 削除: DELETE /api/books/:id
      if (request.method === "DELETE" && m) {
        const { meta } = await env.DB
          .prepare("DELETE FROM books WHERE id = ?")
          .bind(Number(m[1]))
          .run();
        return meta.changes === 0
          ? Response.json({ error: "not found" }, { status: 404 })
          : new Response(null, { status: 204 });
      }
      return Response.json({ error: "not found" }, { status: 404 });
    }
  3. npx wrangler dev で起動し、別ターミナルから一連の CRUD を叩く
    curl -X POST http://localhost:8787/api/books \
      -H "Content-Type: application/json" \
      -d '{"title": "吾輩は猫である", "author": "夏目漱石"}'
    # → {"id":1}
    curl http://localhost:8787/api/books
    # → [{"id":1,"title":"吾輩は猫である","author":"夏目漱石","read":0}]
    curl -X PUT http://localhost:8787/api/books/1 \
      -H "Content-Type: application/json" \
      -d '{"title": "吾輩は猫である", "author": "夏目漱石", "read": true}'
    # → {"updated":1}
    curl -X DELETE http://localhost:8787/api/books/1
    # →(204 No Content)

ポイントは 3 つ。ユーザー入力は必ず bind() 経由で渡すこと、1 件取得は first()(0 件なら null)を使うこと、更新・削除の成否は meta.changes で判定することです。read: true で送った値が読み出しでは 1 になるのは、D1 が Boolean を INTEGER に変換して格納するためです。

ハンズオン 7-4|本番データベースへ適用してデプロイする

ここまでの変更はすべてローカル DB 上の話です。同じマイグレーションを本番 DB に適用し、Worker をデプロイします。約 5 分です。

  1. マイグレーションを本番(リモート)DB に適用する
    npx wrangler d1 migrations apply bookshelf-db --remote
  2. Worker をデプロイし、表示された URL に対して 7-3 と同じ curl を実行する
    npx wrangler deploy
    curl -X POST https://bookshelf-api.<YOUR_SUBDOMAIN>.workers.dev/api/books \
      -H "Content-Type: application/json" \
      -d '{"title": "三四郎", "author": "夏目漱石"}'
    # → {"id":1}
    <YOUR_SUBDOMAIN> は deploy 出力に表示される自分の workers.dev サブドメインに置き換える
  3. ダッシュボードの D1 ページで bookshelf-db を開き、Tables から books の中身が入っていることを確認する

登録 API のレスポンスが返り、ダッシュボードにも行が見えれば、リレーショナルな API が世界中から使える状態になっています。

つまずきポイント

ローカルでは動くのに本番で「no such table」

ローカル DB(.wrangler/state 配下)と本番 DB は完全に別物です。migrations apply --local だけ実行して --remote を忘れると、デプロイ後の Worker はテーブルのない本番 DB を叩いて失敗します。デプロイ前に「--remote で apply したか」を必ず確認しましょう。

D1_TYPE_ERROR — undefined を bind した

リクエスト JSON にキーが欠けていると bind(undefined) になり、クエリは D1_TYPE_ERROR で落ちます(null は合法、undefined は非対応)。外部入力は検証するか body.title ?? null のように正規化してから束縛します。また型変換は一方通行で、Boolean は INTEGER(0 / 1)として格納・読み出しされます。

大量 UPDATE / DELETE が実行時間制限で失敗する

クエリの実行時間は最大 30 秒(2026-08 時点)で、数十万行を 1 文で書き換えるデータ移行はまず失敗します。1,000 行ずつなどのチャンクに分割して繰り返し実行してください。batch() にまとめても、SQL 文長 100 KB などの個別制限は内側の各文に適用され、30 秒制限はバッチ全体にかかります。

migrations の適用先をバインディング名で指定してしまう

d1 migrations apply はバインディング名とデータベース名のどちらも受け付けますが、バインディング名は後から変更でき、意図しない DB に適用する事故の元になります。公式ドキュメントの推奨どおり、変更できないデータベース名(本章では bookshelf-db)で指定しましょう。

理解度チェック

Q1. run() と all() の関係は?また first() は該当行がないとき何を返す?

all() は run() の別名(機能的に等価)で、どちらも results と meta を持つ D1Result を返します。first() は先頭行のオブジェクトを直接返し、該当行がなければ null を返します。

Q2. batch() に渡した複数の文の実行はどう保証される?途中で失敗したら?

各文は順番に・並行せずに実行され、全体が SQL トランザクションとして扱われます。途中の文が失敗するとその文のエラーが返り、シーケンス全体がロールバックされます。複数文の原子性が必要なら batch() を使います。

Q3. リードレプリカを有効にしただけでは読み取りが速くならないのはなぜ?

レプリカへのルーティングは Sessions API(env.DB.withSession())を使ったクエリにだけ働くためです。従来どおりの env.DB.prepare() 直接呼び出しは全てプライマリで実行されます。なお書き込みは常にプライマリへ転送されます。

Q4. 5,000 行のテーブルに対する全表スキャンの SELECT が 1 行だけ返すとき、rows read はいくつ?

5,000 行です。課金は「返した行数」ではなく「スキャンした行数」で数えられます。検索条件の列にインデックスを張れば、スキャン範囲が絞られ rows read(= コスト)とレイテンシの両方が下がります。

Q5. 適用済みマイグレーションの記録はどこに保存される?

対象データベース内の d1_migrations テーブルに記録されます(テーブル名は wrangler.jsonc の migrations_table で変更可能)。migrations apply はこの記録と migrations/ フォルダを突き合わせ、未適用分だけを順に実行します。

まとめと次章へ

D1 で「構造化データの置き場」は手に入りました。次章の Durable Objects は、D1 の裏側も支えている「世界に 1 つだけのステートフルなインスタンス」を直接扱い、リアルタイム協調やロックといった D1 / KV では書けない種類の状態管理を可能にします。