sellIntegration
schema

Designing a D1 schema (tables & migrations)

A good schema is the foundation of every app — design it once, change it safely forever.

2Tables modeled
1:NRelationship
SQLiteSQL dialect
0001+Versioned migrations
insights

What are we designing?

A schema is the blueprint of your database — the list of tables, the columns in each table, and the rules that keep the data clean. In this guide we model a tiny blog: users who write posts. You will learn primary keys, foreign keys, indexes, and the NOT NULL / UNIQUE rules, then manage every future change with migrations.

D1 runs on SQLite, so this is plain, standard SQL. The big idea: design the tables thoughtfully up front, then never hand-edit the live database — instead, write a small migration file for each change so your schema has a clear, versioned history.

schemaThe schema design journey

List your data

Design tables and columns

Add keys and indexes

Write a migration file

Apply with Wrangler

Versioned D1 schema

schema

The data model: users and posts

One user can write many posts, and every post belongs to exactly one user. That is a one-to-many relationship. We link them with a foreign key: posts.user_id points back to users.id.

schemaER diagram: users ↔ posts

writes

USERS

integer

id

PK

auto id

text

email

UK

unique login

text

name

display name

text

created_at

timestamp

POSTS

integer

id

PK

auto id

integer

user_id

FK

owner

text

title

not null

text

body

content

text

status

draft or live

text

created_at

timestamp

key

Primary key (PK)

id uniquely identifies each row. INTEGER PRIMARY KEY in SQLite auto-fills a new number for every insert.

link

Foreign key (FK)

posts.user_id stores the id of the owning user, linking the two tables and keeping data consistent.

manage_search

Index

An index on user_id lets the database jump straight to a user's posts instead of scanning the whole table.

block

NOT NULL

email and name are required — the database rejects any row that leaves them empty.

fingerprint

UNIQUE

email is UNIQUE, so two users can never register with the same address.

account_tree

One-to-many

The crow's-foot mark (o{) means one user relates to many posts — the most common app relationship.

school

Schema vocabulary

These are the building blocks you will use in almost every table you ever design.

schema

Schema

The full definition of your tables, columns, types, and rules — your database's blueprint.

data_object

Data types

SQLite uses TEXT, INTEGER, REAL, BLOB, and NULL. Store timestamps as TEXT (ISO strings) for simplicity.

key

Primary key

The column that uniquely names each row. INTEGER PRIMARY KEY also auto-increments for you.

link

Foreign key

A column pointing at another table's primary key, expressing relationships between rows.

manage_search

Index

A lookup shortcut that speeds up filtering and joins on a column — at the cost of slightly slower writes.

rule

Constraints

Rules like NOT NULL, UNIQUE, and DEFAULT that the database enforces so bad data never gets in.

start

DEFAULT

A value auto-filled when you omit a column, e.g. status defaults to 'draft' and created_at to now.

update

Migration

A numbered SQL file describing one schema change. Run in order, they give your database a version history.

swap_vert

Changing the schema with migrations

Apps grow: you will need a new column, a new table, or a new index. A migration is a small, numbered SQL file (0001_..., 0002_...) that records exactly one change. Wrangler keeps track of which files have already run, so applying them is repeatable on every environment.

The golden rule: once a migration has been applied to a shared or production database, never edit that file. To fix or change something, write a new migration. This keeps the history honest and lets teammates catch up by simply running 'migrations apply'.

schemaMigration workflow

yes

no

Decide the change

wrangler d1 migrations create

Write CREATE/ALTER SQL

apply --local to test

Works?

apply --remote

Versioned schema in D1

history

Why versioned files?

Because the migrations folder lives in Git alongside your code. Anyone can clone the repo and rebuild the exact same schema by running the migrations in order — no manual SQL, no guesswork.

construction

Build it step by step

Now wire it all together: create the database, bind it to a Worker, define the schema in a migration, apply it, add a second migration to evolve the schema, and finally query across both tables with a JOIN.

  1. Create the database

    Wrangler prints a database_id — copy it for the next step.

    bash
    npx wrangler d1 create blog-db
  2. Bind it to your Worker

    Add this to wrangler.jsonc so your Worker reaches the database as env.DB.

    json
    {
      "d1_databases": [
        {
          "binding": "DB",
          "database_name": "blog-db",
          "database_id": "<paste-your-id-here>"
        }
      ]
    }
  3. Create the first migration

    This makes a migrations/ folder and an empty 0001_create_users_and_posts.sql file.

    bash
    npx wrangler d1 migrations create blog-db create_users_and_posts
  4. Write the schema SQL

    Open the generated file and define both tables with their keys, constraints, and indexes.

    sql
    -- migrations/0001_create_users_and_posts.sql
    -- Enforce foreign keys (SQLite leaves this off by default)
    PRAGMA foreign_keys = ON;
    
    CREATE TABLE users (
      id         INTEGER PRIMARY KEY,
      email      TEXT NOT NULL UNIQUE,
      name       TEXT NOT NULL,
      created_at TEXT NOT NULL DEFAULT (datetime('now'))
    );
    
    CREATE TABLE posts (
      id         INTEGER PRIMARY KEY,
      user_id    INTEGER NOT NULL,
      title      TEXT NOT NULL,
      body       TEXT,
      status     TEXT NOT NULL DEFAULT 'draft',
      created_at TEXT NOT NULL DEFAULT (datetime('now')),
      FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    
    -- Index the columns we filter and join on
    CREATE INDEX idx_posts_user_id ON posts (user_id);
    CREATE INDEX idx_posts_status  ON posts (status);
  5. Apply locally, then remotely

    Test against the local copy first; once it works, apply to the real cloud database.

    bash
    # Build the schema on the local test DB
    npx wrangler d1 migrations apply blog-db --local
    
    # See what is still pending on production
    npx wrangler d1 migrations list blog-db --remote
    
    # Apply to the real cloud database
    npx wrangler d1 migrations apply blog-db --remote
  6. Evolve the schema later

    Need a new column? Create a second migration instead of editing the first — then apply it the same way.

    bash
    npx wrangler d1 migrations create blog-db add_published_at_to_posts
    
    # In migrations/0002_add_published_at_to_posts.sql:
    #   ALTER TABLE posts ADD COLUMN published_at TEXT;
    
    npx wrangler d1 migrations apply blog-db --local
    npx wrangler d1 migrations apply blog-db --remote
  7. Query across tables from a Worker

    prepare() builds a safe query, bind() fills the ? placeholder (blocking SQL injection), and the JOIN pulls each post together with its author.

    js
    export default {
      async fetch(request, env) {
        const url = new URL(request.url);
        const userId = url.searchParams.get("user_id") ?? "1";
    
        // Join each post to its author, newest first.
        const { results } = await env.DB
          .prepare(
            `SELECT posts.id, posts.title, posts.status, users.name AS author
             FROM posts
             JOIN users ON users.id = posts.user_id
             WHERE posts.user_id = ?
             ORDER BY posts.created_at DESC`
          )
          .bind(userId)
          .all();
    
        return Response.json(results);
      },
    };
  8. Deploy

    Publish the Worker — your schema-backed API is now live.

    bash
    npx wrangler deploy

Seed some data to test

bashInsert sample rows
npx wrangler d1 execute blog-db --remote --command "INSERT INTO users (email, name) VALUES ('mei@example.com', 'Mei');"

npx wrangler d1 execute blog-db --remote --command "INSERT INTO posts (user_id, title, status) VALUES (1, 'Hello D1', 'live');"
tips_and_updates

Pitfalls & tips

warning

Never edit an applied migration

Once 0001 has run on a shared database, editing it will not re-run, and teammates' databases will drift out of sync. Always add a new numbered migration for the change.

  • Turn on foreign keys with PRAGMA foreign_keys = ON; SQLite ignores FK rules otherwise.
  • Add an index on every foreign key and any column you frequently filter or sort by.
  • SQLite's ALTER TABLE can add columns and rename, but cannot drop a column easily — plan columns thoughtfully.
  • Prefer NOT NULL with a DEFAULT so existing rows stay valid when you add a column.
  • Test every migration with --local before --remote; the local copy is fast and disposable.
  • Keep the migrations/ folder in Git so the whole schema history travels with your code.
savings

Indexes save rows read

D1 bills by rows read and written. A good index lets a query touch only the rows it needs instead of scanning the whole table — faster responses and a smaller bill.