Designing tables with SQL

Each table holds one kind of thing

Say you want to build a small app to keep track of books you have read and who wrote them. You want to use a database, and for an app like this, SQLite can be a good choice. For this learning site, I choose DuckDB because it gives helpful error messages and I wanted to learn one dialect first. Whichever database, it will need tables. A good design for this app is two tables, one for authors and one for books. Once they have some data, they might look like this:

authors

id name
1 Ursula K. Le Guin
2 Emily St. John Mandel
3 N.K. Jemisin

books

id title year author_id
1 The Left Hand of Darkness 1969 1
2 The Dispossessed 1974 1
3 Station Eleven 2014 2
4 The Fifth Season 2015 3
5 The Obelisk Gate 2016 3
6 The Stone Sky 2017 3

Each table holds one kind of thing. Each row is one author or one book, and each column is one fact about it. The author_id column in books connects each book to its author.

Start from an empty database

The playground already has both these tables, filled with the rows above. To create them yourself, you first have to delete them. In SQL, deleting a table is called dropping it:

DROP TABLE books;
DROP TABLE authors;

DROP TABLE removes the table and every row in it. On a real database, that's a reason to stop and be very certain you know what you are doing. Here it's safe: the playground loads its data from a file each time the page opens, so reloading the page brings both tables back. The order matters. Reload the playground and try authors before books. It fails, because each book points to an author through author_id, and DuckDB refuses to drop authors while books exists.

Create tables and connect them

With the old tables gone, these statements create them again, empty and ready for data:

CREATE TABLE authors (
  id   INTEGER PRIMARY KEY,
  name TEXT NOT NULL
);
CREATE TABLE books (
  id        INTEGER PRIMARY KEY,
  title     TEXT NOT NULL,
  year      INTEGER,
  author_id INTEGER NOT NULL REFERENCES authors(id)
);

Each column has a name and a data type. NOT NULL means the value is required: a book must have a title, but its year can be left empty if you don't know it.

The id column is the table's primary key, a value that identifies exactly one row. Two books can have the same title, but never the same id. The DuckDB set up in this playground doesn't fill in the id for you, so you give each row its id when you add it.

books doesn't store the author's name, only a number: author_id, the id of one of the authors. REFERENCES authors(id) tells the database about this link, so it can look after it for you. If you try to add a book by author 9, who doesn't exist, the database says no.

Rules like this one, and NOT NULL above, are called constraints. You write them once, when you create the table, and the database checks them every time the data changes. A column that points to another table, like author_id, is a foreign key. That's also why authors couldn't be dropped first: the books still point to it.

Because of the link, each author's name exists in one place, and if you misspell it, you fix it once. A useful rule of thumb: if you find yourself writing the same value over and over in a column, it probably belongs in its own table.

Fill the tables with INSERT

The new tables are empty. You can check with SELECT:

SELECT * FROM books;

The result shows the column names, id, title, year and author_id, but no rows.

INSERT INTO adds rows. You name the table and its columns, then give the values for each row in the same order as the columns. These statements fill both tables with the rows from the start of the page:

INSERT INTO authors (id, name) VALUES
  (1, 'Ursula K. Le Guin'),
  (2, 'Emily St. John Mandel'),
  (3, 'N.K. Jemisin');
INSERT INTO books (id, title, year, author_id) VALUES
  (1, 'The Left Hand of Darkness', 1969, 1),
  (2, 'The Dispossessed', 1974, 1),
  (3, 'Station Eleven', 2014, 2),
  (4, 'The Fifth Season', 2015, 3),
  (5, 'The Obelisk Gate', 2016, 3),
  (6, 'The Stone Sky', 2017, 3);

Insert the authors first. The foreign key refuses a book whose author_id doesn't match an author that's already in the table.

Run the same SELECT again, and this time it returns the six books.

Test the design by querying it

A good way to check a design is to ask the questions your app will need to answer. Which books have I read, and who wrote them?

SELECT authors.name, books.title, books.year
FROM books
JOIN authors ON authors.id = books.author_id
ORDER BY authors.name, books.year;

The JOIN follows author_id from each book to its row in authors, so the answer lists every book next to the name of its author.

The answer takes one join and no workarounds, which is a good sign for the design. If a question like this is hard to write, that's often a sign the design is missing a table or a column. Your first design won't be your last. When you need something new, you can add it later. Changing the design as you iterate on your app is a normal part of the work.

A join can leave out rows without telling you

Say you want to keep track of authors you'd like to read, before you've read any of their books. Add one:

INSERT INTO authors (id, name) VALUES (4, 'Octavia E. Butler');

Then run the query from the previous section again. The result is the same six rows as before, and Octavia E. Butler isn't in it. There's no error, so nothing tells you a row is missing.

JOIN only keeps rows that have a match in both tables. No book has author_id 4, so the new author has nothing to match, and the join drops her. This kind of join is called an inner join, and JOIN on its own means INNER JOIN.

To keep every author, start from authors and use LEFT JOIN:

SELECT authors.name, books.title, books.year
FROM authors
LEFT JOIN books ON books.author_id = authors.id
ORDER BY authors.name, books.year;

LEFT JOIN keeps every row from the table on its left, the one in FROM, even when there's no match. Where there's no matching book, the book columns are NULL:

name title year
Emily St. John Mandel Station Eleven 2014
N.K. Jemisin The Fifth Season 2015
N.K. Jemisin The Obelisk Gate 2016
N.K. Jemisin The Stone Sky 2017
Octavia E. Butler NULL NULL
Ursula K. Le Guin The Left Hand of Darkness 1969
Ursula K. Le Guin The Dispossessed 1974

Neither join is wrong. They answer different questions. "Which books have I read, and who wrote them?" needs the inner join, because a book list shouldn't include authors without books. "Which authors are in my app?" needs the LEFT JOIN. When you test a design, add a row that has no match, like an author without books, and check that each query still answers the question you meant.