sellIntegration
share

Relationships & JOINs in D1

Model users, posts, and tags the relational way — then pull them back together with a single JOIN.

3Tables linked
1:NOne-to-many
M:NMany-to-many
1 txnAtomic batch
insights

What are we modeling?

Real apps rarely store everything in one giant table. Instead we split data into focused tables and connect them with relationships (a relationship = a rule that says how rows in one table relate to rows in another). In this guide we build a tiny blog: users write posts, and posts are labeled with tags.

Two kinds of relationship cover almost everything: one-to-many (one user writes many posts) and many-to-many (a post has many tags, and a tag is on many posts). Once the data is split, we use a JOIN to glue it back together in one query.

family_history

Think of it like…

A family tree. Each person (a user) can have many children (posts) — that branch is one-to-many. But friendships go both ways: you have many friends and each friend has many friends — that web is many-to-many, and you need a separate list to record who is friends with whom.

schemaThe blog at a glance

1 user writes many posts

each post has many tags

each tag on many posts

Users

Posts

post_tags

Tags

account_tree

The two relationship shapes

Before any SQL, get a feel for the two patterns. The trick is always asking: 'for one row on this side, how many rows on the other side?'

call_split

One-to-many (1:N)

One user writes many posts, but each post has exactly one author. The 'many' side (posts) stores a column pointing back to the 'one' side.

hub

Many-to-many (M:N)

A post has many tags, and a tag is on many posts — both sides are 'many'. Neither table can hold the link on its own.

grid_view

Join table for M:N

Many-to-many needs a third middle table (post_tags) with one row per link: post 10 ↔ tag 'sql'. This is the only honest way to record it.

report

Don't stuff lists into one column

It is tempting to store tags as the text 'sql,cloudflare' in a single posts column. Don't. You can't index it, can't JOIN it, and can't ask 'which posts have the sql tag?' efficiently. Use a join table.

schema

The data model (ER diagram)

An ER diagram (entity-relationship diagram) shows tables as boxes and relationships as connectors. The crow's-foot symbol (the little fork) marks the 'many' end. Here users connect to posts as one-to-many, and posts connect to tags as many-to-many — broken into two one-to-many links through post_tags.

schemausers · posts · tags

writes

has

labels

USERS

int

id

PK

text

email

text

name

POSTS

int

id

PK

int

user_id

FK

text

title

POST_TAGS

int

post_id

FK

int

tag_id

FK

TAGS

int

id

PK

text

name

key

Primary key (PK)

Each table's id uniquely names one row, like a national ID number. No two rows share it.

link

Foreign key (FK)

posts.user_id is a foreign key: it must equal some users.id. It's the pointer that creates the one-to-many link.

grid_view

Composite PK

post_tags uses (post_id, tag_id) together as its key, so the same post-tag pair can't be linked twice.

join_inner

What a JOIN actually does

A JOIN takes rows from two tables and stitches matching ones into a single wider row. You tell it how to match with an ON condition — usually 'foreign key equals primary key'. That's it: it's a lookup that happens inside the database instead of in your code.

schemaTwo rows in, one row out

posts row (user_id = 1)

JOIN ON posts.user_id = users.id

users row (id = 1)

One combined row: title + author name

join_inner

INNER JOIN

Keeps only rows that match on both sides. A post with no matching author simply disappears from the result.

join_left

LEFT JOIN

Keeps every row from the left table even with no match — handy so posts with zero tags still show up (tags come back NULL).

rule

The ON condition

ON posts.user_id = users.id is the rule that pairs rows. Wrong ON = duplicated or missing rows, so double-check it.

construction

Build it end to end

Now wire the whole thing: create the tables (including the join table with foreign keys), seed data, run both kinds of JOIN, write atomically with a batch transaction, then read it from a Worker and a web page.

  1. Bind the database

    After 'wrangler d1 create blog-db', add the binding so your Worker can reach it as env.DB.

    json
    {
      "d1_databases": [
        {
          "binding": "DB",
          "database_name": "blog-db",
          "database_id": "<paste-your-id-here>"
        }
      ]
    }
  2. Create the schema (with FKs)

    post_tags is the join table. It has two foreign keys and a composite primary key. ON DELETE CASCADE means deleting a post auto-removes its link rows.

    sql
    -- users: one row per author
    CREATE TABLE users (
      id    INTEGER PRIMARY KEY,
      email TEXT NOT NULL UNIQUE,
      name  TEXT NOT NULL
    );
    
    -- posts: each post belongs to exactly ONE user  (one-to-many)
    CREATE TABLE posts (
      id      INTEGER PRIMARY KEY,
      user_id INTEGER NOT NULL,
      title   TEXT NOT NULL,
      body    TEXT,
      FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    
    -- tags: one row per reusable label
    CREATE TABLE tags (
      id   INTEGER PRIMARY KEY,
      name TEXT NOT NULL UNIQUE
    );
    
    -- post_tags: the JOIN TABLE linking posts and tags  (many-to-many)
    CREATE TABLE post_tags (
      post_id INTEGER NOT NULL,
      tag_id  INTEGER NOT NULL,
      PRIMARY KEY (post_id, tag_id),
      FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
      FOREIGN KEY (tag_id)  REFERENCES tags(id)  ON DELETE CASCADE
    );
    
    -- index the foreign-key columns so JOINs stay fast
    CREATE INDEX idx_posts_user_id    ON posts(user_id);
    CREATE INDEX idx_post_tags_tag_id ON post_tags(tag_id);
  3. Seed some rows

    Two users, three posts, three tags, and the link rows that connect posts to tags.

    sql
    INSERT INTO users (id, email, name) VALUES
      (1, 'mei@example.com',  'Mei'),
      (2, 'alex@example.com', 'Alex');
    
    INSERT INTO posts (id, user_id, title) VALUES
      (10, 1, 'Hello D1'),
      (11, 1, 'JOINs explained'),
      (12, 2, 'Edge databases');
    
    INSERT INTO tags (id, name) VALUES
      (100, 'sql'),
      (101, 'cloudflare'),
      (102, 'beginner');
    
    -- the many-to-many link rows
    INSERT INTO post_tags (post_id, tag_id) VALUES
      (10, 100), (10, 101),
      (11, 100), (11, 102),
      (12, 101);
  4. Query one-to-many (post + author)

    JOIN posts to users on the foreign key. Each result row now carries the author's name pulled from the users table.

    sql
    -- One-to-many: every post WITH its author's name.
    SELECT posts.id, posts.title, users.name AS author
    FROM posts
    JOIN users ON posts.user_id = users.id
    WHERE users.id = ?
    ORDER BY posts.id DESC;
  5. Query many-to-many (post + tags)

    Hop posts → post_tags → tags. Because a post can match many tags, use GROUP BY + GROUP_CONCAT to fold the tags back into one row per post.

    sql
    -- Many-to-many: every post with a comma list of its tags.
    SELECT posts.id,
           posts.title,
           GROUP_CONCAT(tags.name) AS tags
    FROM posts
    LEFT JOIN post_tags ON post_tags.post_id = posts.id
    LEFT JOIN tags      ON tags.id = post_tags.tag_id
    GROUP BY posts.id
    ORDER BY posts.id;
    
    -- The other direction: which posts carry the 'sql' tag?
    SELECT posts.title
    FROM posts
    JOIN post_tags ON post_tags.post_id = posts.id
    JOIN tags      ON tags.id = post_tags.tag_id
    WHERE tags.name = ?;
  6. Write atomically with batch()

    Creating a post AND its tag links must be all-or-nothing. env.DB.batch() runs every statement in order inside one transaction; last_insert_rowid() returns the id of the post just inserted on the same connection.

    js
    // Create a post AND attach two tags as ONE atomic transaction.
    // If any statement fails, D1 rolls the whole batch back.
    const results = await env.DB.batch([
      env.DB
        .prepare("INSERT INTO posts (user_id, title, body) VALUES (?, ?, ?)")
        .bind(userId, title, body),
      env.DB
        .prepare("INSERT INTO post_tags (post_id, tag_id) VALUES (last_insert_rowid(), ?)")
        .bind(sqlTagId),
      env.DB
        .prepare("INSERT INTO post_tags (post_id, tag_id) VALUES (last_insert_rowid(), ?)")
        .bind(cfTagId),
    ]);
  7. Serve joined data from a Worker

    One endpoint, one query: combine author and tags server-side so the browser receives ready-to-render rows.

    js
    export default {
      async fetch(request, env) {
        const url = new URL(request.url);
    
        // GET /api/users/:id/posts -> each post joined with author + tags
        if (request.method === "GET" && url.pathname.startsWith("/api/users/")) {
          const userId = url.pathname.split("/")[3];
    
          const { results } = await env.DB
            .prepare(
              `SELECT posts.id,
                      posts.title,
                      users.name              AS author,
                      GROUP_CONCAT(tags.name) AS tags
               FROM posts
               JOIN users          ON posts.user_id    = users.id
               LEFT JOIN post_tags ON post_tags.post_id = posts.id
               LEFT JOIN tags      ON tags.id           = post_tags.tag_id
               WHERE users.id = ?
               GROUP BY posts.id
               ORDER BY posts.id DESC`
            )
            .bind(userId)
            .all();
    
          return Response.json(results);
        }
    
        return new Response("Not found", { status: 404 });
      },
    };
  8. Render it in the browser

    The front-end just fetches JSON. Each row already has author and tags — no extra requests, no client-side joining.

    js
    // Browser: load one author's posts. Each row already carries the
    // author name and tag list, thanks to the JOINs on the server.
    const res = await fetch(`/api/users/${userId}/posts`);
    const posts = await res.json();
    
    posts.forEach((post) => {
      // e.g. 'Hello D1 - by Mei - sql,cloudflare'
      console.log(`${post.title} - by ${post.author} - ${post.tags}`);
    });
schemaBatch = one transaction
D1WorkerD1WorkerAll statements run in one transactionalt[every statement succeeds][any statement fails]batch([insert post, link tag1, link tag2])commit and return resultsroll the whole batch back
bolt

One query beats many

Without JOIN you'd fetch posts, then loop and fetch each author and each tag separately — the classic 'N+1' problem. A single JOIN lets the database do that matching once, close to the data.

school

Key terms

call_split

One-to-many

One parent row owns many child rows; each child points back at one parent (posts → users).

hub

Many-to-many

Both sides have many of each other (posts ↔ tags); it can't be stored without a middle table.

grid_view

Join table

A small table (post_tags) whose only job is to hold one row per link between two other tables.

join_inner

JOIN

A SELECT that matches rows from two tables on an ON condition and merges them into wider rows.

key

Foreign key (FK)

A column that must match a primary key in another table — the database can enforce this for you.

lock

Transaction

A group of writes that all succeed or all fail together, leaving the data consistent. batch() gives you one.

tips_and_updates

Pitfalls & tips

fact_check

D1 enforces foreign keys

Insert parents before children: a posts row needs its users row to exist first. For batches where statements depend on each other, you may add PRAGMA defer_foreign_keys = true; to delay the check until the transaction commits.

  • Always index foreign-key columns (user_id, tag_id) so JOINs don't scan whole tables.
  • A JOIN can multiply rows (one post × many tags); use GROUP BY + GROUP_CONCAT to fold them back to one row per post.
  • Use LEFT JOIN when the left side should appear even with no match (a post with zero tags).
  • batch() is atomic and all-or-nothing — ideal for 'create post + its tag links' together.
  • D1 bills by rows read and written; a tight JOIN that reads fewer rows is also cheaper.
  • Keep your CREATE TABLE statements in schema.sql and apply with wrangler d1 execute --file=./schema.sql.