◀ Course contents Part 4 · Module 4-04

INSERT, UPDATE & DELETE

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.

BEGIN;
DELETE FROM orders;          -- 24 rows. Everything.
ROLLBACK;                    -- ...and nothing happened.

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
PostgreSQL
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
    PostgreSQL
    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
      PostgreSQL
      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
        PostgreSQL
        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
          PostgreSQL
          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
            PostgreSQL
            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
              PostgreSQL
              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
                PostgreSQL
                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.

                  BEGIN;
                  DELETE FROM orders; -- 24 rows. Everything.
                  ROLLBACK; -- ...and nothing happened.

                  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
                  PostgreSQL
                  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.

                    1. The one pending order has been stuck for months. Set its status to cancelled.
                    2. The churn events were written by a job that has since been deleted, and nothing reads them. Remove every one.
                    3. 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
                    PostgreSQL
                    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);

                    Output
                    
                          

                      Notification