SQL with Postgres · Lesson 7 of 14 · about 30 minutes

Changing data safely

INSERT, UPDATE and DELETE, and the habits that make them safe: a backup first, a dry run with SELECT, changes by key and never by name, and transactions you can undo. Then the county's problems fixed one at a time, checked against the backup, and the Lesson 6 rules added so they can't come back.

By the end you'll know

  • How UPDATE, DELETE and INSERT change data, and how RETURNING shows what changed
  • How a transaction lets you look at a change before keeping it, or undo it completely
  • Why an UPDATE with no WHERE, or a WHERE on a name, is the classic disaster
  • The routine for a safe change: backup, dry run, change by key, check the count, commit
  • How to compare the data with its backup, and add rules once the data is clean
  • What an upsert is

Before you start

  • You need: Lessons 1 to 6 done, with the larkspur container running and the county as larkspur.sql loaded it. If you've changed it, reload first.
  • Where commands run: inside psql: docker exec -it larkspur psql -U postgres -d larkspur.
  • Heads up: this lesson changes the county for real. Reloading larkspur.sql brings every original mistake back, any time.

Why changing data is different

Everything so far has been reading. A wrong SELECT costs you a confusing answer; run it again. A wrong UPDATE changes rows that other things depend on, and there's no "undo" button. In Lesson 5, a join on a name gave wrong numbers. An update on a name gives wrong data, kept until someone notices.

Think of a surgeon. Before operating: check the chart, mark the spot, count the instruments in. After: count them out again. None of that is the surgery; all of it is why the surgery goes right. Changing data has the same kind of routine, and this lesson is that routine:

  1. Back up the table you're about to change.
  2. Dry run: SELECT the rows your change will touch, and count them.
  3. Change by key, inside a transaction.
  4. Check the count matches the dry run, then commit.
  5. Compare with the backup. Then add a rule so the problem can't come back.

It's CivicGrid's data routine, too: always dry-run, write by a stable key, keep a backup, and run the checks afterwards.

A backup first

The quickest backup is a copy of the table, made with CREATE TABLE ... AS:

SQL
CREATE TABLE leaders_backup AS SELECT * FROM leaders;
CREATE TABLE towns_backup AS SELECT * FROM towns;
OUTPUT
SELECT 20
SELECT 12

Twenty leaders and twelve towns, copied: the rows, but none of the rules or keys. It's a snapshot to compare against and restore from, inside the same database. That makes it a good safety net for one change, and no use at all if the database is lost. Real backups are Lesson 13.

Transactions: try it, then decide

A transaction groups changes so they happen all together or not at all. BEGIN starts one. COMMIT keeps everything in it; ROLLBACK throws it all away, as if it never ran. Until you commit, other connections can't see your changes.

Use one to make the classic mistake, safely: an UPDATE that forgot its WHERE.

SQL
BEGIN;
UPDATE towns SET population = 0;
OUTPUT
BEGIN
UPDATE 12

UPDATE 12: every town. UPDATE changes every row its WHERE matches, and with no WHERE, that's all of them. Look:

SQL
SELECT name, population FROM towns ORDER BY id LIMIT 3;
OUTPUT
     name      | population 
---------------+------------
 Ashford       |          0
 Ashford       |          0
 Brennan Falls |          0
(3 rows)

Inside a transaction, it's only a draft. Throw it away:

SQL
ROLLBACK;
SELECT name, population FROM towns ORDER BY id LIMIT 3;
OUTPUT
ROLLBACK
     name      | population 
---------------+------------
 Ashford       |      48210
 Ashford       |       1904
 Brennan Falls |      21544
(3 rows)

All back. Without the BEGIN, psql commits each statement the moment it runs, called autocommit, and the zeroes would be permanent. Start every change with BEGIN. The count after a change, UPDATE 12, is your first warning: it's the number to compare with your dry run.

By key, never by name

Thomas Reyes has left office, say, and his term needs an end date. Update him by name:

SQL
BEGIN;
UPDATE leaders SET ended = '2026-10-31' WHERE name = 'Thomas Reyes'
RETURNING id, town_id, name, ended;
OUTPUT
 id | town_id |     name     |   ended    
----+---------+--------------+------------
  2 |       1 | Thomas Reyes | 2026-10-31
  4 |       2 | Thomas Reyes | 2026-10-31
(2 rows)

UPDATE 2

Two rows. Both Ashfords' mayors have left office now, though only one did. RETURNING shows exactly which rows a change touched, and here it caught the mistake before it was kept:

SQL
ROLLBACK;

It's the two Ashfords from Lesson 5 again, as a write this time. Names aren't unique, so change rows by their key: WHERE id = 2. Find the id with a SELECT first, which is the dry run.

The fixes

Now the county's problems, one at a time, each the same way.

Fix 1: the trailing space. Dry run: which names have spaces at either end?

SQL
SELECT id, name, length(name) FROM leaders WHERE name <> btrim(name);
OUTPUT
 id |      name      | length 
----+----------------+--------
  8 | Sam Whitfield  |     14
(1 row)

One row, id 8. Change that row, by its id:

SQL
BEGIN;
UPDATE leaders SET name = btrim(name) WHERE id = 8 RETURNING id, name, length(name);
OUTPUT
 id |     name      | length 
----+---------------+--------
  8 | Sam Whitfield |     13
(1 row)

UPDATE 1

UPDATE 1, as the dry run said, and 13 characters now. Keep it:

SQL
COMMIT;

Fix 2: Dunmore's two mayors. Owen Price took over in 2023, and Grace Lin's term was never closed:

SQL
SELECT id, name, started, ended FROM leaders
WHERE town_id = 5 AND role = 'mayor' AND ended IS NULL;
OUTPUT
 id |    name    |  started   | ended 
----+------------+------------+-------
  9 | Grace Lin  | 2019-01-01 | 
 10 | Owen Price | 2023-01-01 | 
(2 rows)

Close hers on the day his began:

SQL
BEGIN;
UPDATE leaders SET ended = '2023-01-01' WHERE id = 9 RETURNING id, name, started, ended;
COMMIT;
OUTPUT
 id |   name    |  started   |   ended    
----+-----------+------------+------------
  9 | Grace Lin | 2019-01-01 | 2023-01-01
(1 row)

UPDATE 1

Fix 3: Carl Webb's term. It starts in 2024 and ends in 2023. Which date is wrong can't be worked out from the data, so this needs a source. In real data, that means the town's own website or records. Here, say Linden's records show he started in 2023, and the year was a typo:

SQL
BEGIN;
UPDATE leaders SET started = '2023-01-01' WHERE id = 20 RETURNING id, name, started, ended;
COMMIT;
OUTPUT
 id |   name    |  started   |   ended    
----+-----------+------------+------------
 20 | Carl Webb | 2023-01-01 | 2023-06-30
(1 row)

UPDATE 1

Fix 4: the phones. Dry run: which don't match the format? !~ means "doesn't match this pattern":

SQL
SELECT id, name, phone FROM towns WHERE phone !~ '^\(\d{3}\) \d{3}-\d{4}$';
OUTPUT
 id |   name   |    phone     
----+----------+--------------
  7 | Fairview | 555-010-7700
(1 row)

Reformat it with regexp_replace: the brackets in the pattern capture the three groups of digits, and \1, \2 and \3 put them back in the new format. And Dunmore's phone, which it shared with Cedar Hollow, was simply wrong: say its website gives (555) 010-5500. Two changes, one transaction:

SQL
BEGIN;
UPDATE towns SET phone = regexp_replace(phone, '^(\d{3})-(\d{3})-(\d{4})$', '(\1) \2-\3')
WHERE id = 7 RETURNING id, name, phone;
UPDATE towns SET phone = '(555) 010-5500' WHERE id = 5 RETURNING id, name, phone;
COMMIT;
OUTPUT
 id |   name   |     phone      
----+----------+----------------
  7 | Fairview | (555) 010-7700
(1 row)

UPDATE 1
 id |  name   |     phone      
----+---------+----------------
  5 | Dunmore | (555) 010-5500
(1 row)

UPDATE 1

And two problems left alone. Glen Oak and Juniper still have no current mayor. Nothing in the data says who it is, and making up a name to fill the gap is worse than a gap. An honest empty is a fact: "we don't know". That's how CivicGrid treats a city whose mayor it can't confirm.

When something fails inside a transaction

Two changes in one transaction: Cedar Hollow's website to https, and a population that isn't a number.

SQL
BEGIN;
UPDATE towns SET website = 'https://cedarhollow.larkspur.us' WHERE id = 4;
UPDATE towns SET population = 'unknown' WHERE id = 5;
OUTPUT
BEGIN
UPDATE 1
ERROR:  invalid input syntax for type integer: "unknown"
LINE 1: UPDATE towns SET population = 'unknown' WHERE id = 5;
                                      ^

The first worked; the second failed. Now try anything at all:

SQL
SELECT name, website FROM towns WHERE id = 4;
OUTPUT
ERROR:  current transaction is aborted, commands ignored until end of transaction block

After an error, a transaction refuses everything until you end it. That's the "all or nothing" promise being kept: half of it failed, so none of it can be kept, and Postgres won't let you carry on as if it hadn't. COMMIT here would roll back too. End it:

SQL
ROLLBACK;
SELECT name, website FROM towns WHERE id = 4;
OUTPUT
ROLLBACK
     name     |            website             
--------------+--------------------------------
 Cedar Hollow | http://cedarhollow.larkspur.us
(1 row)

The website change, which worked, is gone too. That's the point: a group of changes that belong together, like moving money out of one account and into another, either all happens or none of it does.

INSERT, DELETE, and an upsert

INSERT adds a row, and DELETE removes the rows its WHERE matches. Everything above applies to DELETE too: no WHERE means every row, so dry-run it, and use a key.

SQL
INSERT INTO leaders (id, town_id, name, role, started)
VALUES (21, 8, 'Test Person', 'mayor', '2025-01-01') RETURNING id, town_id, name;
DELETE FROM leaders WHERE id = 21 RETURNING id, name;
OUTPUT
 id | town_id |    name     
----+---------+-------------
 21 |       8 | Test Person
(1 row)

INSERT 0 1
 id |    name     
----+-------------
 21 | Test Person
(1 row)

DELETE 1

(The 0 in INSERT 0 1 is left over from older versions of Postgres, and always 0. The 1 is the number of rows.)

Often you have a row that might already be there: new figures for a town that may or may not exist yet. An upsert inserts it, or updates it if the key is already taken. In Postgres, that's ON CONFLICT:

SQL
INSERT INTO towns (id, name, kind, population) VALUES (6, 'Elk Ridge', 'village', 2750)
ON CONFLICT (id) DO UPDATE SET population = EXCLUDED.population
RETURNING id, name, population;
OUTPUT
 id |   name    | population 
----+-----------+------------
  6 | Elk Ridge |       2750
(1 row)

INSERT 0 1

Town 6 already existed, so instead of an error, its population was updated. EXCLUDED means "the row you tried to insert". It still reports INSERT 0 1, so check RETURNING, or the data, to see which happened. Here's what it was:

SQL
SELECT id, name, population FROM towns_backup WHERE id = 6;
OUTPUT
 id |   name    | population 
----+-----------+------------
  6 | Elk Ridge |       2710
(1 row)

Note what the upsert matches on: ON CONFLICT (id), a key. An upsert on a name would merge the two Ashfords, just like the update did.

Compare with the backup

Before you call it done, check what changed against what you meant to change. Join each table to its backup on the key, and keep the rows that differ:

SQL
SELECT l.id, b.name AS was, l.name AS now, b.started AS started_was, l.started,
       b.ended AS ended_was, l.ended
FROM leaders l JOIN leaders_backup b ON b.id = l.id
WHERE (l.name, l.started, l.ended) IS DISTINCT FROM (b.name, b.started, b.ended);
OUTPUT
 id |      was       |      now      | started_was |  started   | ended_was  |   ended    
----+----------------+---------------+-------------+------------+------------+------------
  8 | Sam Whitfield  | Sam Whitfield | 2023-01-01  | 2023-01-01 |            | 
  9 | Grace Lin      | Grace Lin     | 2019-01-01  | 2019-01-01 |            | 2023-01-01
 20 | Carl Webb      | Carl Webb     | 2024-01-01  | 2023-01-01 | 2023-06-30 | 2023-06-30
(3 rows)

Exactly the three fixes, and nothing else. Row 8 looks unchanged, because the only change is the invisible space. Why IS DISTINCT FROM and not <>? Grace Lin's ended went from NULL to a date, and NULL <> '2023-01-01' is unknown, not true, so <> would have hidden her row. IS DISTINCT FROM treats NULL as a value that can differ. Lesson 8 has more of these.

SQL
SELECT t.id, b.phone AS was, t.phone AS now
FROM towns t JOIN towns_backup b ON b.id = t.id
WHERE t.phone IS DISTINCT FROM b.phone;
OUTPUT
 id |      was       |      now       
----+----------------+----------------
  5 | (555) 010-4400 | (555) 010-5500
  7 | 555-010-7700   | (555) 010-7700
(2 rows)

The two phone fixes. (Elk Ridge's population changed too; this only compares phones.)

Now the rules go on

In Lesson 6, every one of these was refused, because the data broke them. Now it doesn't:

SQL
ALTER TABLE leaders ADD CONSTRAINT term_order CHECK (ended IS NULL OR ended >= started);
CREATE UNIQUE INDEX one_current_mayor ON leaders (town_id) WHERE role = 'mayor' AND ended IS NULL;
ALTER TABLE towns ADD CONSTRAINT one_phone_each UNIQUE (phone);
ALTER TABLE leaders ADD CONSTRAINT no_spaces CHECK (name = btrim(name));
ALTER TABLE towns ADD CONSTRAINT phone_format CHECK (phone ~ '^\(\d{3}\) \d{3}-\d{4}$');
OUTPUT
ALTER TABLE
CREATE INDEX
ALTER TABLE
ALTER TABLE
ALTER TABLE

All five accepted. And they work: try to give Dunmore a second current mayor again.

SQL
INSERT INTO leaders (id, town_id, name, role, started) VALUES (22, 5, 'Grace Lin', 'mayor', '2026-01-01');
OUTPUT
ERROR:  duplicate key value violates unique constraint "one_current_mayor"
DETAIL:  Key (town_id)=(5) already exists.

\d leaders shows them, with the names you gave them:

OUTPUT
               Table "public.leaders"
 Column  |  Type   | Collation | Nullable | Default 
---------+---------+-----------+----------+---------
 id      | integer |           | not null | 
 town_id | integer |           |          | 
 name    | text    |           |          | 
 role    | text    |           |          | 
 started | date    |           |          | 
 ended   | date    |           |          | 
Indexes:
    "leaders_pkey" PRIMARY KEY, btree (id)
    "one_current_mayor" UNIQUE, btree (town_id) WHERE role = 'mayor'::text AND ended IS NULL
Check constraints:
    "no_spaces" CHECK (name = btrim(name))
    "term_order" CHECK (ended IS NULL OR ended >= started)
Foreign-key constraints:
    "leaders_town_id_fkey" FOREIGN KEY (town_id) REFERENCES towns(id)

Find, fix, then forbid. Once you're sure, drop the backup copies:

SQL
DROP TABLE leaders_backup, towns_backup;

One thing the rules don't catch: Carl Webb's corrected term, early 2023, overlaps Joy Hart's, who has been Linden's mayor since 2022. Two mayors at once, in the past. No rule here compares one row's dates with another's. Lesson 10 writes that check as a query.

In real systems

  • This is CivicGrid's routine. Every data change is dry-run first, written by id or place_fips and never by city name, backed up first with a dated copy like leaders_backup_20261008_crossstate, and followed by the four checks, stopping if any finds a row.
  • Big changes go in batches. One UPDATE across millions of rows holds locks for a long time, and other queries wait. Real migrations change a few thousand rows at a time, each in its own transaction.
  • A person approves what a script proposes. CivicGrid's weekly verifier never changes a mayor itself. It proposes, and the change happens only when someone approves it at a review page. A transaction's pause before COMMIT is the same idea, for one change.

Spot the problem

Work out what's wrong before you read the explanation.

The cleanup that took a minute too long. Someone needs to delete one bad meeting. They type DELETE FROM meetings;, meaning to go back and add WHERE id = ... before the semicolon once they've looked the id up, and press Enter out of habit. What happened, and what would have saved them? The semicolon made it a complete statement, so psql sent it as it stood, with no WHERE, and with autocommit on, all 30 meetings were deleted and committed immediately. DELETE 30 was the only sign. A BEGIN first would have made it a draft, and ROLLBACK would have brought them back. A backup copy would have let them restore. Writing the WHERE first, and the DELETE FROM meetings in front of it last, avoids it entirely: a habit some people keep for exactly this reason.

Keep this

ToWrite
Copy a table, as a quick backupCREATE TABLE leaders_backup AS SELECT * FROM leaders;
Start a change you can undoBEGIN;
Keep it, or throw it awayCOMMIT;, ROLLBACK;
Change rows, by keyUPDATE leaders SET ended = '2023-01-01' WHERE id = 9;
See what a change touched... RETURNING id, name
Add a rowINSERT INTO t (a, b) VALUES (1, 'x');
Remove rows, by keyDELETE FROM leaders WHERE id = 21;
Insert, or update if it existsINSERT ... ON CONFLICT (id) DO UPDATE SET x = EXCLUDED.x
Trim spacesbtrim(name)
Rewrite text with a patternregexp_replace(phone, pattern, '(\1) \2-\3')
Compare, counting NULL as a valuea IS DISTINCT FROM b
Find what changedjoin to the backup on id, WHERE ... IS DISTINCT FROM ...

Where you are now

You can change data with a way back at every step, and you've cleaned Larkspur County and locked the fixes in with rules. Lesson 8 goes back to the thing that's caught you in almost every lesson so far: NULL, and the other traps that look like ordinary values.

Check yourself

  1. UPDATE towns SET population = 0 reports UPDATE 12. What happened?

  2. You run BEGIN, an UPDATE, then ROLLBACK. What's in the table?

  3. WHERE name = 'Thomas Reyes' updated two rows. What's the safe way?

  4. After an error inside a transaction, every command fails with 'current transaction is aborted'. Why?

  5. Comparing with the backup, why IS DISTINCT FROM instead of <>?

  6. Why could the Lesson 6 rules be added at the end of this lesson, but not before?

Key terms

  • UPDATE, DELETE, INSERT: change, remove and add rows. UPDATE and DELETE affect every row their WHERE matches.
  • RETURNING: shows the rows a change touched.
  • Transaction: changes that happen all together or not at all, between BEGIN and COMMIT or ROLLBACK.
  • Autocommit: psql's default, where each statement outside a transaction is kept immediately.
  • Dry run: a SELECT of the rows a change will touch, run first.
  • Upsert: insert, or update if the key already exists: ON CONFLICT ... DO UPDATE.
  • IS DISTINCT FROM: "not equal", with NULL treated as a value.

Go deeper

  • The PostgreSQL manual page Transactions (opens in a new tab).
  • Try: give Cedar Hollow its https website, properly: dry run, BEGIN, change by id, check the count, COMMIT.
  • Try: BEGIN;, then DELETE FROM meetings;, then SELECT count(*) FROM meetings;, then ROLLBACK; and count again. Then never do it without the BEGIN.
  • To put every mistake back, reload: \q, then Lesson 1's load command.