Skip to content

Beginner · Building the backend · topic 9 of 34 · 30 min

Creating the database: schema, migrations and seed data

Explanation

In module 2 you designed the users and notes tables on paper. Now you create them for real. Each change to the database is saved as a small numbered file called a migration, applied in order and recorded in a table, so every laptop and server ends up with the same tables. Seed data fills a development database with sample rows.

From design to a real database

The schema is the full list of tables, columns and rules (NOT NULL, UNIQUE, foreign keys) in a database. In module 2 you wrote it down. To make it real, you run the CREATE TABLE statements against a database.

You could type them once into a database tool and forget them. That breaks quickly: your teammate's laptop, the test server and the production server would each need the same typing, and nobody would know which changes each one already has.

sql
-- migrations/001_create_users_notes.sql
CREATE TABLE users (
  id INTEGER PRIMARY KEY,
  name TEXT NOT NULL,
  email TEXT UNIQUE NOT NULL,
  password_hash TEXT NOT NULL,
  created_at TEXT
);

CREATE TABLE notes (
  id INTEGER PRIMARY KEY,
  user_id INTEGER NOT NULL REFERENCES users(id),
  title TEXT NOT NULL,
  body TEXT,
  pinned INTEGER DEFAULT 0,
  created_at TEXT,
  updated_at TEXT
);
The first migration: the module 2 design saved as a file in the project.

What is a migration?

A migration is a small file that changes the schema by one step: create a table, add a column, add an index. Files are numbered (001, 002, 003...) or start with a date, so everyone runs them in the same order. They live in the git repository next to the code.

Think of it like this: Think of the minutes book of a housing society. Every decision gets a numbered entry: "12: build a water tank", "13: paint the gate green". Nobody tears out an old page. If entry 13 was a mistake, entry 14 says "paint the gate blue". A new secretary reads from entry 1 to the end and knows exactly how the society stands today. Migrations are the minutes book of your database.
  1. A migration runner (a small program) reads the migrations folder.
  2. It looks at a tracking table in the database, often called schema_migrations, which lists the files already applied.
  3. It runs only the missing files, oldest first, usually each inside a transaction (all of it happens, or none of it).
  4. After each file succeeds, it writes the file name into the tracking table, so it never runs twice.

Up and down

The "up" part of a migration makes the change. Some tools also let you write a "down" part that undoes it, useful on your laptop when you want to try again. On a live server, teams usually fix a mistake with a new "up" migration instead.

sql
-- migrations/002_add_archived.sql
-- up
ALTER TABLE notes ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;

-- down (undo)
ALTER TABLE notes DROP COLUMN archived;
Adding a column later. Old rows get the default value 0, so nothing breaks.

ALTER TABLE changes an existing table. When you add a NOT NULL column to a table that already has rows, give it a DEFAULT, or the database will refuse because the old rows would have no value.

ToolHow migrations are writtenGood to know
Plain SQL files + a small scriptYou write each .sql file by handSimple and clear; you write the runner yourself (you will, in the task below)
Prisma MigrateYou edit schema.prisma; Prisma writes the SQL fileprisma migrate dev on your laptop, prisma migrate deploy on the server
Drizzle KitYou edit TypeScript table definitions; drizzle-kit generate writes SQLdrizzle-kit migrate applies the files
KnexJavaScript files with up and down functionsknex migrate:latest and knex migrate:rollback

Seed data

Seed data is a set of sample rows (a few users and notes) loaded into a development or test database so the app has something to show. Keep seeds in a separate file or script, not inside migrations, because migrations also run on production and production must not get fake users.

sql
-- seeds/dev.sql (development only)
INSERT INTO users (id, name, email, password_hash, created_at)
VALUES (1, 'Asha', 'asha@example.com', 'hash-a', '2026-01-05 10:00');

INSERT INTO notes (user_id, title, body, pinned, created_at, updated_at)
VALUES (1, 'Physics formulas', 'Ohm law: V = IR', 1, '2026-02-01 08:00', '2026-02-01 08:00');
A tiny seed file. The note gets its id automatically because id is the INTEGER PRIMARY KEY.
Tip: Every few days, delete your local database and rebuild it: run all migrations from 001, then the seed. If that works, any new teammate (and your CI server) can build the same database in one command.
Careful: Never edit a migration file that has already run anywhere else (a teammate's laptop, staging or production). Those databases will not run it again, so your change silently never reaches them. Write a new migration instead.

Watch the lesson

Creating the database: schema, migrations and seed data · 1/7

Design on paper -> real tables

In module 2 we designed the tables on paper. Now we create them for real, in a way the whole team can repeat.

Prefer a video by a teacher? Find free videos on “database migrations explained for beginners sql” on YouTube ↗

In real life: A college results site adds a new column

A college results site stores students and marks. One year the university adds grace marks. The developer does not open the live database and type commands. She writes migration 014_add_grace_marks.sql (ALTER TABLE marks ADD COLUMN grace INTEGER NOT NULL DEFAULT 0), runs all migrations on her laptop, then on the test server, and the deploy script runs the same file on production. Every copy of the database now has the column, and the tracking table shows exactly when it arrived.

Practice (0/3 done)

Task 1 · Code · SQL

Only the users table exists (Asha is user 1). 1) Create the notes table with the columns id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id), title TEXT NOT NULL, body TEXT, pinned INTEGER DEFAULT 0, created_at TEXT, updated_at TEXT. 2) Insert note 1 for Asha: title "Exam dates", body "Maths on 12 March", both timestamps "2026-03-01 10:00", and do NOT give a pinned value, so the default is used. 3) Insert note 2 for Asha: title "Fees due", body "Pay by 15 March", pinned 1, same timestamps. 4) Finally SELECT id, title, pinned FROM notes, sorted by id.

Task 2 · Code · SQL

The Notes database already has data. Write migration 002 and use it. 1) Add a column archived to notes: INTEGER NOT NULL with DEFAULT 0. 2) Archive Asha's "Shopping list" note (id 2) by setting archived to 1. 3) Finally SELECT id, title, archived for all of Asha's notes (user_id = 1), sorted by id.

Task 3 · Code · Javascript

Write the heart of a migration runner. pendingMigrations(all, applied) gets all = the file names in the migrations folder (in any order) and applied = the names already in the schema_migrations table. Return a NEW array of the files not applied yet, sorted by name (so 001 runs before 002). Do not change the input arrays.

Quick quiz

Question 1

1.What is a database migration?

Question 2

2.How does a migration runner know which files to skip?

Question 3

3.Migration 004 already ran on production, and you notice a typo in a column name. What should you do?

Question 4

4.Why should seed data NOT be inside a migration?

Question 5

5.You add archived INTEGER NOT NULL to a notes table that already has rows. What else does the column need?

Read more (free)

Pass the quiz (60% or more) or mark this topic as done when you are ready.

Your progress is saved on this device only.

← Previous: Planning screens and user flows