Your application code lives in Git, with a full history: every change is a commit, and you can see who changed what and when. But your database schema โ the CREATE TABLE statements that define your tables โ has had no such history. So far you have run that SQL by hand, once, wherever you needed it.
That falls apart the moment more than one database exists. You already have at least two: the Postgres in your Docker Compose stack, and the Neon database you are about to deploy to. Soon there could be a teammate's copy as well. How do you make sure they all have the same tables? And when you add a column next week, how does every database get that change, in the right order?
The answer is database migrations: you treat every schema change as a small, numbered SQL file that lives in your project, in Git, next to your code.
<aside> ๐ก
You have already written your first migration without knowing it. The schema.sql you loaded into Postgres in the Docker Compose chapter is a schema change in a file. This chapter turns that one-off file into a proper, ordered system.
</aside>
Instead of one schema.sql that you keep editing, you keep a folder of change files, each with a version number and a description:
src/main/resources/db/migration/
V1__create_customers_table.sql
V2__create_orders_table.sql
V3__add_email_to_customers.sql
Three rules make this work, and they are the whole concept:
<aside> โ
Why never edit an applied migration? Because a database only runs each file once. If you edit V2 after it has already run, the databases that ran the old V2 will never see your change โ but a fresh database will run your new V2. Now your databases silently disagree about their own structure. Forward-only is the rule that prevents this.
</aside>
A migration is just plain SQL โ the same CREATE TABLE and ALTER TABLE you already know from Week 5:
-- V2__create_orders_table.sql
CREATE TABLE orders (
id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL REFERENCES customers(id),
total_cents INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
A later change is a new file, never an edit of the one above:
-- V3__add_email_to_customers.sql
ALTER TABLE customers ADD COLUMN email TEXT;
Running a migration against a database is just running its SQL file. Against your Neon database, using psql with the connection string:
psql "$DATABASE_URL" -f src/main/resources/db/migration/V2__create_orders_table.sql
You run each new file, in order, against each database that needs it. On a fresh database you run them all from V1; on a database that already has V1 and V2, you run only V3.
There is one hard part. Look at the last sentence above: "a database that already has V1 and V2". How do you know which migrations a given database has already run? If you lose track, you will either skip a migration or run one twice โ both break your database.
The standard solution is simple: the database keeps a little table listing the migrations it has run.
CREATE TABLE schema_version (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Every time you apply a migration, you record it:
INSERT INTO schema_version (version) VALUES ('V2');
Now, to update any database, the recipe is always the same: look at schema_version to see what it already has, then run every migration file that is not listed yet, in order, recording each one.
<aside> ๐ก
This tracking table is exactly what migration tools build and manage for you automatically. Tools like Flyway and Liquibase (both common in Java and Spring Boot projects) scan your db/migration folder, check their version table, and apply whatever is missing โ in one command. You are doing by hand what they automate, so that you understand what they are actually doing. You do not need to set one up in this course.
</aside>
This is why the chapter sits here, right after deployment. When you ship a new version of your app that expects a new column, the order matters:
<aside> โ
Run the migration before you deploy the app version that needs it. If the new code goes live first and the column does not exist yet, every request that touches it fails until the migration catches up. Schema first, then the code that depends on it.
</aside>
That means: run your migrations against Neon first (by hand, with psql or the Neon SQL Editor), confirm the tables are there, and only then deploy your app on Render.
DROP TABLE and DROP COLUMN on anything real are irreversible โ be certain before you write one.V4__add_index_on_email.sql tells you what it does at a glance; V4__update.sql does not.The HackYourFuture curriculum is licensed underย CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

Built with โค๏ธ by the HackYourFuture community ยท Thank you, contributors
Found a mistake or have a suggestion? Let us know in the feedback form.