You do not need relational algebra to ship a product. You need to know what a table is, how two tables point at each other, and what happens when you change the shape of one that already holds real customer records. That is roughly a page of material, and skipping it is how people end up with a live database nobody dares touch.
The idea
A database is a set of tables. A table is one kind of thing: users, orders, posts. Its columns are the fields every one of those things has, and each column has a type, so a price column holds numbers and refuses the word "free". A row is one actual thing: one user, one order. Tables point at each other by storing the other row's id, and every structural change you make gets written down as a A small file of database instructions that changes the shape of your tables. You run it once, and it runs the same way everywhere. so your laptop, your test copy and production all end up identical.
users orders
+----+------------------+ +----+---------+-------+-----------+
| id | email | | id | user_id | cents | status |
+----+------------------+ +----+---------+-------+-----------+
| 1 | [email protected] | | 11 | 1 | 4000 | paid |
| 2 | [email protected] | | 12 | 1 | 2000 | refunded |
+----+------------------+ | 13 | 2 | 4000 | paid |
+----+---------+-------+-----------+
id is the primary key. No two rows in a table share one.
orders.user_id -------> users.id this pointer is the foreign key
orders 11 and 12 belong to ana, who is user 1. order 13 belongs to bo.
an order whose user_id matches no users.id is refused by the database.How it works
The id column is the primary key, a value no two rows share. When orders.user_id stores 1,
the database can enforce that a row with id 1 exists in users, and it will refuse an order for a
user who does not. That refusal is the point. It is cheaper than discovering six months of orphaned
rows.
You ask questions in SQL: select * from orders where user_id = 1 and status = 'paid'. If that
question gets slow, you add an A lookup shortcut you add to a column so the
database can find matching rows without reading every row. on the column you filter by.
Changing structure works differently from changing data. Adding a status column is a migration: a
file that lives in git, runs once per database, and is never edited after it has run anywhere. If it
was wrong, you write the next one to correct it. The name starts with something that sorts, so the
files run in the order you wrote them, and the tools all use a timestamp for that. The exact shape
differs: the Supabase CLI writes 20260812143022_add_order_status.sql, Rails writes a .rb file
with the same timestamp, Prisma makes a folder per migration with a migration.sql inside. Each
keeps its own record of which ones a given database has already seen, so running the command twice
is safe.
"Once per database" assumes more than one. You want two from the beginning: the real one your users touch, and a scratch one on your laptop or a second free project that you can break without consequence. Migrations run against the scratch copy first, every time. Working directly against the database real people are using is the setup where every later warning on this page becomes a bad evening.
What to do
- Sketch your tables before you ask an AI to build them. Names, columns, and which table points at which. Five minutes here saves a rebuild later.
- Store money as whole cents in an integer column and timestamps in UTC. Both mistakes are painful to undo once real rows exist.
- Never open the production table editor and fix a row by hand. Write the change as a statement instead, run it in the SQL editor, and paste it into a file you keep. A hand edit through a table's grid leaves no record, so nobody can tell later what changed or reproduce it on the copy running on your laptop.
- Take a backup before running a migration against real data, and prove you can read it back. On
Supabase's free plan the dashboard has no backups to download, so this is on you. Use
supabase db dumpfrom their command-line tool, orpg_dumpagainst your connection string, which works against any Postgres. Restore it once into your scratch database while nothing is wrong. A backup you have never restored is a file you hope is a backup.
Where it breaks
Migrations are forward-only in practice. Dropping a column is instant and unrecoverable, and "undo" means restoring a backup, so treat a destructive migration as a separate, deliberate piece of work. Foreign keys also cause deletes to fail, which reads as a bug and is usually the database correctly refusing to strand rows. And an AI assistant will happily generate a schema that looks professional and models the wrong thing. It cannot know that your orders can have two payers. You can.