All 22 modules are open from the start — nothing here is
locked, and nothing costs anything. Sign in so your progress, titles and
credentials stay with you, on every device you use.
Free forever, with your Google account. No password, no payment.
The statements that change things, and the ones that undo them
Every module so far has read. These three write — and the syntax is the
easiest in the course, which is precisely the problem. The dangerous version
of each is shorter than the correct one, runs without complaint,
and reports its success in the same tone either way.
Ready?
1
Adding Rows
INSERT names a table, names the columns, and supplies
values.
INSERT INTO products (name, category, price, launched_on)
VALUES ('Archive Pro', 'addon', 15.00, DATE '2024-06-01');
Always name the columns. Leaving the list off and
relying on the table's column order works right up until somebody adds a
column in the middle — and then values land in the wrong fields, with no
error at all if the types happen to line up.
product_id is absent because the table generates it. That
raises an obvious question: what id did it get? Selecting the row back
by name is a guess, since two products could share one.
INSERT INTO products (...) VALUES (...)
RETURNING product_id, name;
RETURNING hands back the rows actually written, generated
columns included. It works on UPDATE and
DELETE as well — RETURNING on a
DELETE gives you exactly what you just removed, which is
the cheapest undo log there is.
Inserting what a query found
When the rows come from the database rather than your keyboard, a
SELECT replaces the VALUES entirely — no
brackets, no keyword:
INSERT INTO subscriptions (user_id, plan, mrr, started_on)
SELECT u.user_id, u.plan, 0, u.signup_date
FROM users u
WHERE NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.user_id = u.user_id);
Columns line up by position and type — the same rule as
UNION in module 3-04. That is how a backfill is written:
one statement, however many rows the query finds. Note the
NOT EXISTS doing the real work; without it this inserts a
duplicate row for every user who already had one.
Nothing here can break anything
The practice database is rebuilt from its seed before
every single run. A DELETE with the
WHERE left off costs you one press of the Run button.
That is exactly the safety net a real database does not give you,
which is what the rest of this module is about.
Quick check
An INSERT … SELECT backfill is run twice by mistake. What happens the second time?
2
The Most Expensive Mistake in SQL
UPDATE sets columns on the rows a WHERE
selects. The SET can compute from the row's own values, so
a percentage rise needs no arithmetic from you:
UPDATE products
SET price = price * 1.10
WHERE category = 'addon'; -- 3 rows
Now remove one line.
UPDATE products SET price = 0; -- 8 rows. The whole catalogue is free.
That is valid SQL. It changes every row in the table, reports how many,
and gives no warning whatsoever. DELETE FROM orders; —
shorter than the correct version — empties the table the same way.
The dangerous version is always the shorter one
This is what makes it so common. Every other mistake in this course
takes effort to make — a wrong join, a missing DISTINCT, the
wrong frame. This one takes a moment's distraction and a Run button.
Three habits prevent essentially all of it:
1
Write the WHERE first
Type it before the SET. Then it cannot be forgotten — only edited.
2
SELECT it before you change it
Run the same WHERE as a SELECT, look at the rows, then swap the verb. It takes seconds.
3
Check the count against your expectation
"3 rows affected" when you expected 3 is a genuine test. 8 is a fire alarm — and the only one you get.
Updating from another table
When the new value depends on a different table,
UPDATE … FROM joins inside the update — the
WHERE then carries both the join condition and the filter:
UPDATE products p
SET category = 'review'
FROM orders o
WHERE o.product_id = p.product_id
AND o.status = 'refunded';
One caution the syntax hides: if the join matches a row
several times, it is still updated once, using
an arbitrary one of the matches. It does not apply twice. When the new
value depends on which match won, aggregate in a subquery first rather
than trusting the join to pick well.
Quick check
You meant to update one customer's email. The statement reports "4,812 rows affected". What is the first thing to do?
3
The Database Refusing to Help You
Try to delete a user and it stops you:
DELETE FROM users WHERE user_id = 11;
ERROR: update or delete on table "users" violates foreign key
constraint "subscriptions_user_id_fkey" on table "subscriptions"
That is a foreign key working. Rows in
subscriptions and events point at that user,
and removing them would leave those rows referring to a user who does
not exist. The database refuses rather than permitting the orphan.
This is the constraint doing its job, not an obstacle to route around.
The technique is simply to delete in dependency order — children first,
parent last:
DELETE FROM events WHERE user_id = 11;
DELETE FROM subscriptions WHERE user_id = 11;
DELETE FROM users WHERE user_id = 11;
ON DELETE CASCADE is convenient and worth being nervous about
A foreign key can be declared to remove the children automatically.
That turns three careful statements into one — and it means a single
DELETE can quietly remove thousands of rows from tables
you were not thinking about at the time. Convenience and blast radius
are the same property here.
The constraints in this database are the ones from the Postgres
migration doing real work: NOT NULL rejects a missing
value, NUMERIC(10,2) refuses a price that is not a number,
a DATE column rejects the 31st of February outright, and
the primary keys refuse a duplicate. Every one of those is an error
you get instead of bad data — which is the trade the whole
design exists to make.
Quick check
A DELETE on a parent row fails with a foreign key error. Which fix should worry you most?
4
Being Able to Take It Back
A transaction groups statements so that they succeed or
fail together. Between BEGIN and COMMIT
nothing is permanent, and ROLLBACK discards all of it.
This is the real safety net for a risky change, and the workflow is
worth making automatic: BEGIN, run the statement,
SELECT to see what it actually did, and only then
COMMIT or ROLLBACK — having looked.
The row count is not evidence of anything persisting
The rolled-back DELETE above still reports 24 rows
affected. The count says what the statement did, not what
survived. That makes it a useful check before you
commit, and no proof at all afterwards.
The other half of the guarantee is all-or-nothing across a failure. If
the third of three statements errors, the first two are undone as well —
which is what stops a migration finishing halfway and leaving the data
in a state no code expects.
Two more worth knowing
⇅
INSERT … ON CONFLICT
An upsert: insert, or update the row that is already there. ON CONFLICT (id) DO UPDATE SET price = EXCLUDED.price, where EXCLUDED is the row you tried to insert.
✂
TRUNCATE
Empties a table without touching rows individually, so it is far faster. It cannot be filtered, and in most databases it cannot be rolled back — Postgres is unusual in allowing it inside a transaction.
Next: the capstone
Module 4-05 is the last one. No new syntax — a single report that
needs joins from Part 3, windows from 4-01 and 4-02, and the judgement
from 4-03 about what a number actually means.
Quick check
Inside a transaction you run three statements; the second fails. You then COMMIT. What is saved?
0 of 9 completed
Loading the tables…
01
To do
Everything in this course so far has read. INSERT is the
first statement that changes anything.
INSERT INTO products (name, category, price, launched_on) VALUES ('Archive Pro', 'addon', 15.00, DATE '2024-06-01');
Name the columns. It is tempting to leave the list off and rely on
column order, and it breaks silently the day somebody adds a column in
the middle — values land in the wrong fields, with no error if the types
happen to line up.
product_id is not in the list because the table generates
it. A statement that changes rows returns no rows, so what you get back
is a count rather than a result grid.
Nothing you do here can break anything. The database is rebuilt from its
seed before every single run.
Your task: add a product called
Archive Pro, category addon, price
15.00, launched 2024-06-01.
query.sql
PostgreSQLCtrl↵ to run
Hint
VALUES ('Archive Pro', 'addon', 15.00, DATE '2024-06-01'); — text in single quotes, and DATE in front of the date literal.
Output
02
To do
The row now exists, but you do not know its product_id —
the database chose it. Selecting it back by name is a guess: two
products could share a name.
RETURNING hands back the rows the statement actually wrote,
generated columns included:
INSERT INTO products (...) VALUES (...) RETURNING product_id, name;
This is a Postgres speciality and worth knowing. It works on
UPDATE and DELETE too — RETURNING
on a DELETE gives you what you just removed, which is the cheapest
possible undo log.
Your task: insert the same product again, returning
product_id and name.
query.sql
PostgreSQLCtrl↵ to run
Hint
RETURNING product_id, name; on the end.
Output
03
To do
VALUES is for rows you are typing. When the rows come from
the database itself, a SELECT goes where the
VALUES would be — no brackets, no VALUES
keyword.
INSERT INTO subscriptions (user_id, plan, mrr, started_on) SELECT user_id, plan, 0, signup_date FROM users WHERE ...;
The columns have to line up by position and type, the
same rule as UNION in module 3-04. It is how a backfill is
written: one statement, however many rows the query finds.
Your task: two users have no subscription row at all.
Give each of them one, on their own plan, with
mrr of 0 and started_on equal to
their signup_date. Use NOT EXISTS to find
them, from module 3-05.
query.sql
PostgreSQLCtrl↵ to run
Hint
SELECT u.user_id, u.plan, 0, u.signup_date FROM users u WHERE NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.user_id = u.user_id);
Output
04
To do
UPDATE sets columns on the rows a WHERE
selects. The SET can compute from the row's own current
values, so a percentage rise needs no arithmetic on your part.
UPDATE products SET price = price * 1.10 WHERE category = 'addon';
The WHERE is the same clause you have written since module
1-02, and it does the same thing — it decides which rows. The only
difference is that the consequence is now permanent.
Your task: raise the price of every
addon by 10%.
query.sql
PostgreSQLCtrl↵ to run
Hint
SET price = price * 1.10 WHERE category = 'addon'; — three products are addons.
Output
05
To do
An UPDATE without a WHERE is valid SQL. It
changes every row in the table, reports how many, and
offers no warning of any kind.
UPDATE products SET price = 0; -- 8 rows. The whole catalogue is free.
The same is true of DELETE FROM orders; — no error, no
confirmation, just an empty table. This is the single most expensive
mistake in everyday SQL, and the habits that prevent it are small:
1
Write the WHERE first
Type the WHERE before the SET. Then it cannot be forgotten, only edited.
2
SELECT it before you change it
Run the same WHERE as a SELECT and look at the rows. Then swap the verb.
3
Check the count against the answer
"3 rows affected" when you expected 3 is a real test. 8 is a fire alarm.
Your task: the careful version. Set
price to 0 for the single product
nobody has ever ordered — and nothing else. Its name is
Archive Add-on.
query.sql
PostgreSQLCtrl↵ to run
Hint
The starter is the disaster — it has no WHERE. Add WHERE name = 'Archive Add-on', or find it with a NOT EXISTS against orders.
Output
06
To do
Sometimes the new value depends on a different table.
UPDATE … FROM lets you join inside the update: the
FROM names the other table, and the WHERE
carries the join condition as well as the filter.
UPDATE products p SET price = price * 2 FROM orders o WHERE o.product_id = p.product_id AND o.status = 'refunded';
One caution the syntax hides: if the join matches a product
several times, the row is still updated
once, using an arbitrary one of the matches. It does not double
twice. When the new value depends on which match won, aggregate in a
subquery first rather than trusting the join.
Your task: two orders were refunded. Mark the products
they were for by setting their category to
review.
query.sql
PostgreSQLCtrl↵ to run
Hint
SET category = 'review' FROM orders o WHERE o.product_id = p.product_id AND o.status = 'refunded';
Output
07
To do
DELETE takes a WHERE and removes the rows it
selects. There is no SET, because there is nothing to set —
which makes the missing-WHERE version even shorter, and
even easier to run by accident.
DELETE FROM orders WHERE status = 'refunded';
DELETE FROM orders; — six more characters removed — empties
the table.
There is also TRUNCATE, which empties a table far faster by
not touching the rows individually. It cannot be filtered, and in most
databases it cannot be rolled back. Postgres is unusual in allowing
TRUNCATE inside a transaction.
Your task: delete the refunded orders.
query.sql
PostgreSQLCtrl↵ to run
Hint
WHERE status = 'refunded'; — two orders were refunded.
Output
08
To do
Try to delete a user and the database stops you:
DELETE FROM users WHERE user_id = 11; ERROR: update or delete on table "users" violates foreign key constraint "subscriptions_user_id_fkey" on table "subscriptions"
That is a foreign key doing its job. Rows in
subscriptions and events point at that user,
and removing them would leave those rows referring to nothing. The
database refuses rather than allowing the orphan.
This is the constraint working, not an obstacle. Delete the children
first, deepest first, and the parent goes last — and doing it in one
statement per table, in the right order, is the whole technique.
A table can be declared ON DELETE CASCADE, which makes the
children disappear automatically. Convenient, and worth being nervous
about: one DELETE can then quietly remove thousands of rows
from tables you were not thinking about.
Your task: remove user 11 completely — their events,
their subscription, then the user. Three statements, in an order that
never orphans anything.
query.sql
PostgreSQLCtrl↵ to run
Hint
DELETE FROM events WHERE user_id = 11; then DELETE FROM subscriptions WHERE user_id = 11; then the user. Children before parent.
Output
09
To do
A transaction groups statements so they succeed or fail
together. Between BEGIN and COMMIT nothing is
permanent, and ROLLBACK discards the lot.
This is the real safety net for a risky change: BEGIN, run
the statement, SELECT to see what it did, then
COMMIT or ROLLBACK having actually looked.
Note that the rolled-back DELETE still reports 24 rows
affected. The count says what the statement did, not what
survived — so it is a useful check before you commit, and no
evidence at all that anything persisted.
Transactions are also all-or-nothing across a failure. If the third of
three statements errors, the first two are undone too, which is what
stops a half-finished migration.
Your task: inside a transaction, delete every order,
then roll it back so all 24 survive.
query.sql
PostgreSQLCtrl↵ to run
Hint
ROLLBACK; on the last line. COMMIT would make it permanent, and the checks would find an empty table.
Output
The data cleanup
To do
Three fixes have been sitting in the backlog. Do all three, in one go,
without touching anything else.
The one pending order has been stuck for months. Set its status to cancelled.
The churn events were written by a job that has since been deleted, and nothing reads them. Remove every one.
Two users have no subscriptions row. Give each one on their own plan, with mrr of 0 and started_on equal to their signup_date.
Every statement needs a WHERE or a condition that limits it —
one of these is an UPDATE, one a DELETE and one
an INSERT … SELECT, and all three are one careless clause
away from touching the whole table. Six rows should change in total.
query.sql
PostgreSQLCtrl↵ to run
Hint
UPDATE orders SET status = 'cancelled' WHERE status = 'pending'; then DELETE FROM events WHERE event_name = 'churn'; then INSERT INTO subscriptions (user_id, plan, mrr, started_on) SELECT u.user_id, u.plan, 0, u.signup_date FROM users u WHERE NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.user_id = u.user_id);