Unit 2 · Module 3

Migrations Are Not Failure

Schema evolution as a managed practice, not a confession

The myth that needs to die first

A common, quiet belief among people learning to build apps is that experienced developers design the schema correctly on day one and never have to change it afterward. That belief makes any schema refactor feel like proof that you didn't think hard enough up front.

It isn't true, and the evidence is hiding in plain sight. Every serious database tool — Supabase, Prisma, Drizzle, Rails, Django, Laravel, every one of them — has an entire subsystem called migrations, built specifically to support changing the schema over time. If schema-from-day-one were achievable, that whole subsystem would not exist. The fact that every framework treats migrations as a first-class feature tells you everything about how often schemas actually evolve in real projects.

What a migration actually is

A migration is a named, ordered file containing SQL (or equivalent) that changes the database's schema. Each migration represents one logical unit of work — a single coherent change, which might be one statement or twenty. The grouping is about what belongs together, not about a per-statement limit.

Migrations are written once, committed to the codebase like any other file, and applied in order. The database keeps track of which migrations it has already run, so applying the same migration twice does nothing the second time. That makes the schema reproducible: any developer (or any environment — local, staging, production) can run the migrations in order and end up with the same schema.

A small migration — one change, one statement

-- supabase/migrations/20260415120000_add_phone_to_clients.sql

ALTER TABLE clients
  ADD COLUMN phone TEXT;

That's a small migration. The long number at the front of the filename is a timestamp, which is how the system knows the order to apply migrations in. Each new migration gets a newer timestamp, so the order is determined automatically without anyone having to renumber anything.

A more typical migration in a real project is much bigger. Shipping a new feature usually means adding several related tables, their enums, their indexes, and the RLS policies that govern who can read or write the rows — all in one file, because none of those pieces make sense without the others:

A more typical feature migration

-- supabase/migrations/20260420090000_add_tagging_feature.sql

-- 1. Enum has to come first — the tags table references it
CREATE TYPE tag_category AS ENUM ('topic', 'mood', 'audience');

-- 2. The tables for the feature
CREATE TABLE tags (
  id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name      TEXT UNIQUE NOT NULL,
  category  tag_category NOT NULL
);

CREATE TABLE post_tags (
  post_id UUID REFERENCES posts(id) ON DELETE CASCADE,
  tag_id  UUID REFERENCES tags(id)  ON DELETE CASCADE,
  PRIMARY KEY (post_id, tag_id)
);

-- 3. Indexes for the queries this feature will run
CREATE INDEX idx_post_tags_tag ON post_tags(tag_id);

-- 4. RLS so only the post's owner can attach tags to it
ALTER TABLE post_tags ENABLE ROW LEVEL SECURITY;

CREATE POLICY "owners can tag their own posts"
  ON post_tags FOR ALL
  USING (
    EXISTS (
      SELECT 1 FROM posts
      WHERE posts.id = post_tags.post_id
        AND posts.user_id = auth.uid()
    )
  );

Everything in that file belongs to "adding the tagging feature." Shipping half of it (the tables but not the policies, or the policies but not the indexes) would leave the system in a broken state. SQL runs top to bottom, so the order inside the file matters — the enum has to exist before the column that uses it, the tables have to exist before the indexes built on them, RLS has to be enabled before policies are created on it.

Analogy: The version history of the database

Code lives in a git repo where every change is recorded as a commit. You can read the history, you can roll back, you can see what changed and when.

Migrations are the same idea applied to the database schema. Each migration file is a commit on the schema. Running the migrations in order reconstructs the current schema from a series of small, readable changes. The schema has a history, the same way the code does.

Why migrations, instead of just changing the schema directly?

The Supabase dashboard's table editor lets you change a schema by clicking buttons. Add a column with a few clicks. Drop a table with one more. That's the equivalent of editing a production file by hand. It works in the moment and leaves no record afterward.

Migrations solve the problems that click-to-edit creates:

What a few real migrations look like

To make the pattern concrete, here are a few migrations representing common changes:

Adding a column

-- 20260415120000_add_timezone_to_clients.sql

ALTER TABLE clients
  ADD COLUMN timezone TEXT NOT NULL DEFAULT 'America/Chicago';

Creating a new table

-- 20260420090000_create_tags_table.sql

CREATE TABLE tags (
  id    UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name  TEXT UNIQUE NOT NULL
);

CREATE TABLE post_tags (
  post_id UUID REFERENCES posts(id),
  tag_id  UUID REFERENCES tags(id),
  PRIMARY KEY (post_id, tag_id)
);

A real refactor: splitting clients into people and businesses

-- 20260501140000_split_clients_into_people_and_businesses.sql

-- 1. Create the new tables
CREATE TABLE people (
  id     UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name   TEXT NOT NULL,
  email  TEXT UNIQUE NOT NULL
);

CREATE TABLE businesses (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  legal_name  TEXT NOT NULL,
  tax_id      TEXT UNIQUE
);

-- 2. Move data over (simplified — real migrations check the kind field)
INSERT INTO people (id, name, email)
SELECT id, name, email FROM clients WHERE kind = 'person';

INSERT INTO businesses (id, legal_name, tax_id)
SELECT id, name, tax_id FROM clients WHERE kind = 'business';

-- 3. Drop the original table once everything is moved
-- (In practice, this step often comes in a later migration after
-- application code has been updated to read from the new tables.)
DROP TABLE clients;

The first two are tiny. The third is what a real "I picked the wrong schema and need to fix it" migration looks like. It's not catastrophic. It's a procedure. Three steps, applied in order, with a clear plan.

The Supabase workflow, briefly

In a Supabase project, migrations live in supabase/migrations/ inside the repo. Two common workflows:

Both produce the same kind of artifact: a numbered file in the migrations folder. The schema's history lives there and is part of the project's permanent record.

Why even senior developers run migrations all the time

Reasons that come up in real projects, not as failures, but as ordinary parts of the work:

None of these are mistakes. They're the schema responding to reality. Migrations are the tool that makes that response controlled and reversible instead of chaotic and silent.

The mental shift this page is asking for

The shift is small but real: schemas are not built, they are grown. The first version is a best guess. The second version reflects what the product turned out to need. The fifth version, two years in, looks nothing like the first and is also exactly right for what the system actually does now.

Migrations are the medium for that growth. They make every change auditable, repeatable, and reversible. A project with a long migration history isn't a project that got it wrong over and over — it's a project that took its schema seriously enough to record every change.

The last page of this module brings every concept so far into one place: designing a schema for a real product description, one decision at a time.