Relationships & JOINs in D1
Model users, posts, and tags the relational way — then pull them back together with a single JOIN.
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.
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.
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?'
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.
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.
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.
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.
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.
Primary key (PK)
Each table's id uniquely names one row, like a national ID number. No two rows share it.
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.
Composite PK
post_tags uses (post_id, tag_id) together as its key, so the same post-tag pair can't be linked twice.
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.
INNER JOIN
Keeps only rows that match on both sides. A post with no matching author simply disappears from the result.
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).
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.
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.
Bind the database
After 'wrangler d1 create blog-db', add the binding so your Worker can reach it as env.DB.
{ "d1_databases": [ { "binding": "DB", "database_name": "blog-db", "database_id": "<paste-your-id-here>" } ] }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.
-- 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);Seed some rows
Two users, three posts, three tags, and the link rows that connect posts to tags.
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);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.
-- 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;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.
-- 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 = ?;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.
// 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), ]);Serve joined data from a Worker
One endpoint, one query: combine author and tags server-side so the browser receives ready-to-render rows.
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 }); }, };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.
// 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}`); });
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.
Key terms
One-to-many
One parent row owns many child rows; each child points back at one parent (posts → users).
Many-to-many
Both sides have many of each other (posts ↔ tags); it can't be stored without a middle table.
Join table
A small table (post_tags) whose only job is to hold one row per link between two other tables.
JOIN
A SELECT that matches rows from two tables on an ON condition and merges them into wider rows.
Foreign key (FK)
A column that must match a primary key in another table — the database can enforce this for you.
Transaction
A group of writes that all succeed or all fail together, leaving the data consistent. batch() gives you one.
Pitfalls & tips
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.
Related products
menu_bookOfficial docsopen_in_new