Schemas change. Always. You need somewhere to store customers' phone numbers, the VARCHAR(60) turns out to be too small, somebody realises that column was badly named, marketing wants a total column in orders so they don't have to recompute it for every report, and a business rule that until now lived in the code really belongs in a CHECK. The database you created in 05-01 and filled in 05-02 isn't a monument: it's a living organism in production that will mutate dozens of times.

ALTER TABLE is the statement that makes that possible. Its syntax takes twenty minutes to learn. What separates a professional from an accident isn't the syntax: it's knowing which operations lock the table and which don't, understanding that in PostgreSQL a migration can be undone with ROLLBACK and in MySQL it can't, knowing the pattern that lets you change a schema without stopping the service, and accepting the rule that governs the whole industry: no structural change is ever typed by hand into a production console.

This lesson covers both halves, and closes the module.

⚠️ Safety warning

ALTER TABLE is a destructive operation, irreversible in its effects on the data. A DROP COLUMN removes an entire column and all its contents; an ALTER COLUMN TYPE can truncate values; an ADD CONSTRAINT can reject existing rows.

  • Run all the examples on your practice database (greenstore), never in production.
  • Take a backup beforehand: pg_dump -U sql_course -d greenstore -f backup.sql.
  • Work inside BEGINROLLBACK: in PostgreSQL the DDL is transactional and you can undo it.
  • On a real system, every schema change must be reviewed by the database owner, tested beforehand in an environment equivalent to production and applied as a versioned migration, never by hand.

Contents

  1. ALTER TABLE: the map of operations
  2. ADD COLUMN, with and without a DEFAULT
  3. DROP COLUMN
  4. RENAME COLUMN and RENAME TO
  5. ALTER COLUMN TYPE and the USING clause
  6. SET / DROP NOT NULL and SET / DROP DEFAULT
  7. ADD / DROP CONSTRAINT, and the NOT VALID + VALIDATE technique
  8. What locks the table and what doesn't
  9. Transactional DDL: PostgreSQL against MySQL
  10. The expand/contract pattern
  11. Versioned migrations
  12. What to do and what not to do in production
  13. Common Mistakes and Tips
  14. Exercises
  15. Module conclusion

  1. ALTER TABLE: the map of operations

ALTER TABLE [IF EXISTS] table_name action [, action ...];

The most common actions:

Action What it does
ADD COLUMN col TYPE [constraints] Adds a column
DROP COLUMN col [CASCADE] Removes a column and its data
RENAME COLUMN old TO new Renames a column
RENAME TO new_table Renames the table
ALTER COLUMN col TYPE new_type [USING expr] Changes the type
ALTER COLUMN col SET NOT NULL / DROP NOT NULL Adds or removes the obligation
ALTER COLUMN col SET DEFAULT expr / DROP DEFAULT Adds or removes the default value
ADD CONSTRAINT name ... Adds a CHECK, UNIQUE, PRIMARY KEY or FOREIGN KEY
DROP CONSTRAINT name Removes a constraint
VALIDATE CONSTRAINT name Validates a constraint declared NOT VALID

One syntax detail that saves a lot of time: several actions fit in a single statement, separated by commas. And that isn't just elegance — it means the table is locked once instead of N times:

ALTER TABLE customers
    ADD COLUMN phone      VARCHAR(20),
    ADD COLUMN newsletter BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE
greenstore=> \d customers
                            Table "public.customers"
     Column      |          Type          | Nullable |           Default
-----------------+------------------------+----------+----------------------------------
 id              | integer                | not null | generated by default as identity
 name            | character varying(60)  | not null |
 last_name       | character varying(90)  | not null |
 email           | character varying(120) | not null |
 city            | character varying(80)  |          |
 country         | character varying(60)  | not null |
 signup_date     | date                   | not null | CURRENT_DATE
 referred_by_id  | integer                |          |
 phone           | character varying(20)  |          |
 newsletter      | boolean                | not null | false

A warning about this lesson's objects. customers.phone, customers.newsletter and orders.total are one-off examples: they aren't part of GreenStore's canonical schema and they won't appear in the following modules. Reload greenstore.sql when you're done.

  1. ADD COLUMN, with and without a DEFAULT

(Reload greenstore.sql before going on: this section's examples add the previous section's two columns again, this time one by one.)

Without a DEFAULT

ALTER TABLE customers ADD COLUMN phone VARCHAR(20);
ALTER TABLE

The column is born nullable and every existing row is left with NULL. It's instantaneous regardless of the table's size: PostgreSQL just records the new column in its catalogue, without touching a single byte of data.

SELECT id, name, last_name, phone FROM customers ORDER BY id LIMIT 3;
id name last_name phone
1 Lucía Martínez Soler (null)
2 Carlos Ferrer Ibáñez (null)
3 Marta Sanchis Gil (null)

With a DEFAULT

ALTER TABLE customers
ADD COLUMN newsletter BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE
SELECT COUNT(*) FILTER (WHERE NOT newsletter) AS no_newsletter, COUNT(*) AS total
FROM customers;
no_newsletter total
15 15

All fifteen rows have FALSE. And here's one of PostgreSQL's most important improvements of the last decade:

Since PostgreSQL 11, adding a column with a constant DEFAULT no longer rewrites the table. Before that, this operation walked through and rewrote every row to store the value: on a table of a hundred million rows that meant hours of exclusive locking — the classic case study of "how to take production down with one line of SQL".

Now PostgreSQL keeps the default value in the catalogue and returns it "on the fly" for the old rows, writing it physically only when those rows get updated for some other reason. The operation goes from hours to milliseconds.

The exception, which is still expensive:

-- ⚠️ This DOES rewrite the entire table: the DEFAULT is volatile
ALTER TABLE customers
ADD COLUMN token UUID NOT NULL DEFAULT gen_random_uuid();

If the DEFAULT isn't constant —a random function, a NOW() that has to differ per row— each row needs its own value and there's no possible shortcut. The rule:

DEFAULT Does it rewrite the table?
None No
Constant (0, FALSE, 'pending') No (PostgreSQL 11+)
Stable function evaluated once (CURRENT_DATE) No: the value is fixed when the ALTER runs
Volatile function (gen_random_uuid(), random()) Yes, row by row

Dialect note: MySQL 8 with InnoDB allows ADD COLUMN with ALGORITHM=INSTANT in many cases (a column at the end of the table, with no change of row format); otherwise it rebuilds. Oracle has had an equivalent optimisation since 11g. SQLite adds columns at the end cheaply but doesn't allow adding them with a non-constant DEFAULT. SQL Server distinguishes between adding a nullable column (instantaneous) and a NOT NULL one with a DEFAULT (instantaneous since 2012 Enterprise, a rewrite in the other editions).

  1. DROP COLUMN

ALTER TABLE customers DROP COLUMN newsletter;
ALTER TABLE

This is instantaneous too: PostgreSQL doesn't erase the data, it marks the column as dropped in the catalogue and stops showing it. The space is reclaimed when each row is rewritten (by an UPDATE or by a VACUUM FULL).

That has two implications worth knowing:

  1. The data is still physically on disk until the row is rewritten. If the column contained sensitive information, a DROP COLUMN is not a secure erase.
  2. The operation is irreversible from SQL once committed. There's no UNDROP.

If anything depends on the column (a constraint, a view, an index), DROP COLUMN fails:

ALTER TABLE products DROP COLUMN price;
ERROR:  cannot drop column price of table products because other objects depend on it
DETAIL:  constraint products_price_check on table products depends on column price of table products
HINT:  Use DROP ... CASCADE to drop the dependent objects too.

With CASCADE it takes whatever depends on it with it:

ALTER TABLE products DROP COLUMN price CASCADE;
NOTICE:  drop cascades to constraint products_price_check on table products
ALTER TABLE

And with that you've just destroyed the catalogue's most important column along with its constraint. CASCADE in ALTER TABLE deserves the same respect as in DELETE.

  1. RENAME COLUMN and RENAME TO

ALTER TABLE reviews RENAME COLUMN comment TO text;
ALTER TABLE reviews RENAME TO ratings;
ALTER TABLE
ALTER TABLE

Both are purely catalogue operations: instantaneous, without touching data, and reversible with another RENAME.

And both are, on a system with applications connected, among the most dangerous operations there are. The reason isn't technical: it's that the instant you commit the change, all the code mentioning the old name stops working:

SELECT comment FROM reviews;
ERROR:  relation "reviews" does not exist
LINE 1: SELECT comment FROM reviews;
                            ^

A two-second ALTER TABLE has broken the whole application. And there's no transition window: either the code uses the old name, or it uses the new one.

The rule: a RENAME in production is never done in one go. It's done with section 10's expand/contract pattern, or it isn't done at all. And if the name is ugly but it works, very often the right answer is to leave it ugly.

We undo the two changes before going on:

ALTER TABLE ratings RENAME TO reviews;
ALTER TABLE reviews RENAME COLUMN text TO comment;

  1. ALTER COLUMN TYPE and the USING clause

ALTER TABLE products ALTER COLUMN price TYPE NUMERIC(12,2);
ALTER TABLE

Widening a NUMERIC's precision works straight off, because every existing value fits in the new type. The same happens going from VARCHAR(60) to VARCHAR(120) or to TEXT: PostgreSQL recognises those cases as binary-compatible and rewrites nothing.

Narrowing, on the other hand, can fail:

ALTER TABLE customers ALTER COLUMN name TYPE VARCHAR(5);
ERROR:  value too long for type character varying(5)

And this is good news: PostgreSQL checks every row before applying the change, and if a single one doesn't fit, it aborts without touching anything. It never truncates silently.

Dialect note: MySQL, depending on its SQL mode, can truncate silently when narrowing a VARCHAR. With sql_mode at STRICT_TRANS_TABLES (the default since MySQL 5.7) it raises an error; in relaxed mode, it trims the text and emits a warning almost nobody reads. It's one of the most dangerous behavioural differences between engines.

The USING clause

When the conversion isn't automatic, PostgreSQL tells you and offers you the way out:

-- A one-off example: an auxiliary table with "dirty" data from an import
CREATE TABLE imported_orders (
    id          INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    reference   VARCHAR(20) NOT NULL,
    date_text   VARCHAR(10) NOT NULL,
    amount_text VARCHAR(15) NOT NULL
);

INSERT INTO imported_orders (reference, date_text, amount_text) VALUES
('GS-2026-001', '2026-03-01', '47.05'),
('GS-2026-002', '2026-03-02', '26.70'),
('GS-2026-003', '2026-03-03', '34.48');
CREATE TABLE
INSERT 0 3

A direct attempt to convert the text into a date:

ALTER TABLE imported_orders ALTER COLUMN date_text TYPE DATE;
ERROR:  column "date_text" cannot be cast automatically to type date
HINT:  You might need to specify "USING date_text::date".

USING takes an expression that computes the new value from the old one, row by row:

ALTER TABLE imported_orders
    ALTER COLUMN date_text   TYPE DATE          USING date_text::DATE,
    ALTER COLUMN amount_text TYPE NUMERIC(10,2) USING amount_text::NUMERIC(10,2);
ALTER TABLE
SELECT id, reference, date_text, amount_text FROM imported_orders ORDER BY id;
id reference date_text amount_text
1 GS-2026-001 2026-03-01 47.05
2 GS-2026-002 2026-03-02 26.70
3 GS-2026-003 2026-03-03 34.48

USING accepts any expression, not just conversions. For instance, to move order_lines.discount from a fraction (0.10) to a whole percentage (10):

-- A hypothetical example: do NOT apply it to GreenStore, you'd break
-- every calculation from modules 2 to 4
ALTER TABLE order_lines
ALTER COLUMN discount TYPE SMALLINT USING (discount * 100)::SMALLINT;

Three warnings about ALTER COLUMN TYPE:

Warning Detail
It rewrites the entire table except in the binary-compatible cases With an exclusive lock for the whole process
It rebuilds the indexes that include it An additional, proportional cost
It can invalidate dependent constraints and views PostgreSQL recreates them if it can, and if it can't, it fails

  1. SET / DROP NOT NULL and SET / DROP DEFAULT

-- Making a column mandatory that wasn't
ALTER TABLE customers ALTER COLUMN city SET NOT NULL;
ERROR:  column "city" of relation "customers" contains null values

It fails if there are rows with NULL… except that in GreenStore every customer has a city, which is the case here. Let's try one that does have nulls:

ALTER TABLE orders ALTER COLUMN employee_id SET NOT NULL;
ERROR:  column "employee_id" of relation "orders" contains null values

The ten web orders prevent it, and rightly so: that NULL means something (04-03).

The inverse operation always works, because relaxing a constraint can never invalidate existing data:

ALTER TABLE products ALTER COLUMN price DROP NOT NULL;
ALTER TABLE
ALTER TABLE products ALTER COLUMN price SET NOT NULL;   -- we put it back as it was
ALTER TABLE

Default values work the same way, and they're pure catalogue:

ALTER TABLE products  ALTER COLUMN stock SET DEFAULT 10;
ALTER TABLE products  ALTER COLUMN stock SET DEFAULT 0;    -- we put it back as it was
ALTER TABLE customers ALTER COLUMN signup_date DROP DEFAULT;
ALTER TABLE customers ALTER COLUMN signup_date SET DEFAULT CURRENT_DATE;
ALTER TABLE
ALTER TABLE
ALTER TABLE
ALTER TABLE

Changing the DEFAULT doesn't affect the existing rows. It only changes what will be filled in on future insertions. Expecting otherwise is a classic mistake.

  1. ADD / DROP CONSTRAINT, and the NOT VALID + VALIDATE technique

Here 05-01's insistence on naming constraints pays off: to drop one you have to name it, and you can't name what you don't know the name of.

ALTER TABLE products
ADD CONSTRAINT chk_products_margin CHECK (cost IS NULL OR cost <= price);
ALTER TABLE

It works because none of the 20 products has a cost higher than its price. If one did, PostgreSQL would reject the whole ALTER TABLE:

ERROR:  check constraint "chk_products_margin" of relation "products" is violated by some row

Dropping it:

ALTER TABLE products DROP CONSTRAINT chk_products_margin;
ALTER TABLE

And the other three families:

ALTER TABLE reviews ADD CONSTRAINT uq_reviews_product_customer UNIQUE (product_id, customer_id);

-- On the import auxiliary table from section 5
ALTER TABLE imported_orders
ADD CONSTRAINT uq_imported_orders_ref UNIQUE (reference);

ALTER TABLE imported_orders
ADD CONSTRAINT chk_imported_orders_amount CHECK (amount_text >= 0);
ALTER TABLE
ALTER TABLE
ALTER TABLE

The problem: validating locks

Adding a CHECK or a FOREIGN KEY forces PostgreSQL to check every existing row, and it does so with an exclusive lock. On a table of ten million rows that can be a long minute during which nobody can read or write.

The two-stage solution:

-- Stage 1: add without validating. Instantaneous.
ALTER TABLE products
ADD CONSTRAINT chk_products_margin CHECK (cost IS NULL OR cost <= price) NOT VALID;
ALTER TABLE
-- Stage 2: validate. It may take a while, but it does NOT block reads or writes.
ALTER TABLE products VALIDATE CONSTRAINT chk_products_margin;
ALTER TABLE

What NOT VALID does exactly:

With NOT VALID After VALIDATE CONSTRAINT
New or modified rows They're checked from the very first instant They're checked
Existing rows They aren't checked They're checked once
Lock ACCESS EXCLUSIVE, but instantaneous SHARE UPDATE EXCLUSIVE: it blocks neither reads nor writes
Can the planner take advantage of it No Yes

That is: NOT VALID gives you the protection going forward immediately, and leaves the check on the history for a quieter moment. It's the standard technique for adding constraints to large tables in production.

And to check which constraints are unvalidated:

SELECT conname AS constraint_name, convalidated AS validated
FROM   pg_constraint
WHERE  conrelid = 'products'::regclass
ORDER BY conname;
constraint_name validated
chk_products_margin true
products_category_id_fkey true
products_cost_check true
products_pkey true
products_price_check true
products_stock_check true
products_supplier_id_fkey true

We undo the example:

ALTER TABLE products DROP CONSTRAINT chk_products_margin;

  1. What locks the table and what doesn't

This is the section that separates a harmless migration from a production outage.

PostgreSQL protects each operation with a lock level. The most aggressive is ACCESS EXCLUSIVE: while it's held, no other session can even read the table. Every query simply waits.

Operation Lock level Rewrites? Scans? Duration
ADD COLUMN with no DEFAULT ACCESS EXCLUSIVE No No Instantaneous
ADD COLUMN with a constant DEFAULT ACCESS EXCLUSIVE No (PG 11+) No Instantaneous
ADD COLUMN with a volatile DEFAULT ACCESS EXCLUSIVE Yes Yes Proportional to the size
DROP COLUMN ACCESS EXCLUSIVE No No Instantaneous
RENAME COLUMN / RENAME TO ACCESS EXCLUSIVE No No Instantaneous
SET DEFAULT / DROP DEFAULT ACCESS EXCLUSIVE No No Instantaneous
DROP NOT NULL ACCESS EXCLUSIVE No No Instantaneous
SET NOT NULL ACCESS EXCLUSIVE No Yes Proportional
ALTER COLUMN TYPE (binary-compatible) ACCESS EXCLUSIVE No No Instantaneous
ALTER COLUMN TYPE (everything else) ACCESS EXCLUSIVE Yes Yes Proportional
ADD CONSTRAINT CHECK ACCESS EXCLUSIVE No Yes Proportional
ADD CONSTRAINT CHECK ... NOT VALID ACCESS EXCLUSIVE No No Instantaneous
ADD FOREIGN KEY ACCESS EXCLUSIVE on both tables No Yes Proportional
ADD FOREIGN KEY ... NOT VALID SHARE ROW EXCLUSIVE No No Instantaneous
VALIDATE CONSTRAINT SHARE UPDATE EXCLUSIVE No Yes Blocks neither reads nor writes
ADD UNIQUE / ADD PRIMARY KEY ACCESS EXCLUSIVE No Yes (it builds an index) Proportional
DROP CONSTRAINT ACCESS EXCLUSIVE No No Instantaneous
CREATE INDEX SHARE No Yes Blocks writes
CREATE INDEX CONCURRENTLY SHARE UPDATE EXCLUSIVE No Yes (two passes) Doesn't block writes (module 8)

The detail that kills: the lock queue

And now the truly important part, which almost nobody explains:

An "instantaneous" operation isn't harmless if it can't get the lock.

An ALTER TABLE ADD COLUMN takes a millisecond… once it has obtained the ACCESS EXCLUSIVE. If at that moment there's a long query reading the table, the ALTER starts waiting. And while it waits, PostgreSQL queues every new query behind it, because locks are granted in order of arrival.

sequenceDiagram
    participant R as Report (5 min)
    participant A as ALTER TABLE
    participant N as 200 new queries
    R->>R: long SELECT · ACCESS SHARE lock
    A->>A: asks for ACCESS EXCLUSIVE → WAITS
    N->>N: ask for ACCESS SHARE → they wait BEHIND the ALTER
    Note over R,N: 💥 The table is unreachable for 5 minutes<br/>because of a 1 ms ALTER

A one-millisecond ALTER TABLE has just made the table unreachable for five minutes. The standard protection:

BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE customers ADD COLUMN phone VARCHAR(20);
COMMIT;

If it can't get the lock within three seconds, it aborts with canceling statement due to lock timeout instead of blocking the database. It gets retried later. Setting lock_timeout in every migration is one of the most profitable practices there is.

  1. Transactional DDL: PostgreSQL against MySQL

This is a critical difference between engines, and it completely changes how a migration is written.

In PostgreSQL, the DDL is transactional:

BEGIN;

ALTER TABLE customers ADD COLUMN phone VARCHAR(20);
ALTER TABLE customers ADD COLUMN newsletter BOOLEAN NOT NULL DEFAULT FALSE;

SELECT id, name, phone, newsletter FROM customers ORDER BY id LIMIT 2;
id name phone newsletter
1 Lucía (null) false
2 Carlos (null) false
ROLLBACK;
ROLLBACK
SELECT id, name, phone FROM customers LIMIT 1;
ERROR:  column "phone" does not exist
LINE 1: SELECT id, name, phone FROM customers LIMIT 1;
                   ^

As if it had never happened. The two ALTER TABLEs have been undone.

The practical consequence is enormous: in PostgreSQL you can wrap a fifteen-step migration in BEGINCOMMIT and have the guarantee that either it's applied in full or nothing is applied. If step 12 fails, the schema is left exactly as it was.

PostgreSQL MySQL / MariaDB SQL Server Oracle SQLite
Transactional DDL Yes No Yes No Yes
ROLLBACK of an ALTER TABLE It works Impossible It works Impossible It works
BEGIN before a DDL statement Respected Implicitly commits the open transaction Respected Implicitly commits Respected
Half-applied migration possible No Yes No Yes No

In MySQL and Oracle, every DDL statement implicitly commits the transaction in progress. If a fifteen-step migration fails at step twelve, the first eleven are already applied and there's no way to undo them. That's why on those engines every migration needs its hand-written reversal script, and why the tools in section 11 insist so much on the concept of a down migration.

If you work with MySQL, internalise this: the safety net doesn't exist. Each migration step has to be reversible on its own, and the order matters a great deal more.

  1. The expand/contract pattern

How to change a schema without stopping the service.

The underlying problem: the database and the application are deployed separately, and for a while the old and new versions of the code coexist. Any change that breaks either of them causes errors. A RENAME COLUMN, as you saw, breaks the old version the instant it's committed.

Expand/contract (also called parallel change) solves this by breaking the change into five phases, each of them compatible both forwards and backwards:

flowchart TD
    A["1 · EXPAND<br/>Add the new thing<br/>without touching the old"] --> B["2 · DOUBLE WRITE<br/>The application writes<br/>to both places"]
    B --> C["3 · BACKFILL<br/>Fill the new one<br/>with the historical data"]
    C --> D["4 · SWITCH THE READS<br/>The application reads from the new one<br/>and it's verified that they match"]
    D --> E["5 · CONTRACT<br/>Stop writing to the old one<br/>and remove it"]
    style A fill:#e8f5e9
    style E fill:#ffebee

The key is that at no point does a state exist in which the application can fail: between phase 1 and phase 5, both versions of the code work.

The case: adding orders.total

GreenStore computes each order's total by adding up its lines and adding the shipping. That's correct (01-05 justified it: no precomputed aggregates), but module 4's reports repeat that expression over and over, and at high volume the cost shows. Management asks for a denormalised total column.

Phase 1 — Expand: add the column, nullable.

ALTER TABLE orders ADD COLUMN total NUMERIC(10,2);
ALTER TABLE

Instantaneous, nullable, with no DEFAULT. The old code keeps working: it doesn't know the column exists and it doesn't need it.

Phase 2 — Double write. A version of the application is deployed that, as well as creating the order and its lines, fills in total. New orders have a value; old ones are still NULL. No schema change in this phase — it's pure code deployment.

Phase 3 — Backfill: filling in backwards.

With this module's tools, with no subqueries, in two steps:

CREATE TEMP TABLE order_totals AS
SELECT ol.order_id,
       ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS goods_amount
FROM   order_lines AS ol
GROUP BY ol.order_id;
SELECT 20
UPDATE orders AS o
SET    total = t.goods_amount + o.shipping_cost
FROM   order_totals AS t
WHERE  t.order_id = o.id
  AND  o.total IS NULL;
UPDATE 20

Notice the AND o.total IS NULL: it makes the UPDATE idempotent (05-03) and, above all, it guarantees the backfill doesn't overwrite the values phase 2 is already writing in real time. It's the detail that turns a dangerous backfill into a safe one.

On a real table the backfill would be done in batchesWHERE total IS NULL AND id BETWEEN 1 AND 10000, repeated— so as not to hold a giant transaction locking rows.

Phase 4 — Switch the reads, and verify.

Before the reports start using the new column, you have to check it matches the live calculation:

SELECT o.id,
       o.order_date,
       o.status,
       o.total                                        AS total_column,
       ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2)
           + o.shipping_cost                          AS computed_total,
       o.total - (ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2)
           + o.shipping_cost)                         AS difference
FROM   orders      AS o
JOIN   order_lines AS ol ON ol.order_id = o.id
GROUP BY o.id, o.order_date, o.status, o.total, o.shipping_cost
ORDER BY o.id
LIMIT 8;
id order_date status total_column computed_total difference
1 2025-03-04 delivered 47.05 47.05 0.00
2 2025-03-12 delivered 26.70 26.70 0.00
3 2025-04-02 delivered 34.48 34.48 0.00
4 2025-04-19 delivered 36.70 36.70 0.00
5 2025-05-07 delivered 32.10 32.10 0.00
6 2025-05-23 cancelled 31.70 31.70 0.00
7 2025-06-11 delivered 37.10 37.10 0.00
8 2025-06-28 delivered 74.78 74.78 0.00

(The first 8 of 20 rows.)

And the global check, which is the one that really matters:

SELECT COUNT(*)   AS orders,
       SUM(total) AS column_sum,
       COUNT(*) FILTER (WHERE total IS NULL) AS not_filled
FROM   orders;
orders column_sum not_filled
20 846.20 0

€846.20: exactly module 4's canonical figure (€727.95 of goods + €118.25 of shipping). Twenty orders, none left unfilled. The backfill is correct.

In this phase, the professional thing is to leave the comparison running for a few days with an alert: if total ever stops matching the sum of the lines, the double write has a hole in it.

Phase 5 — Contract: consolidate and clean up.

ALTER TABLE orders ALTER COLUMN total SET NOT NULL;
ALTER TABLE

Now that every row has a value, the column can be mandatory. And the old calculation is removed from the code.

One problem is left open, and it's worth saying clearly: orders.total is a precomputed aggregate and it has to be kept in sync. If somebody modifies an order line tomorrow with a direct UPDATE, total goes stale and nobody notices. The three solutions —a trigger that recomputes it, a materialized view, or a generated column if the formula lived in the same row— belong to module 10. It's exactly 01-05's warning, section 8: denormalise with the measurements in hand, and document how you're going to keep it up to date.

The variant: splitting name and last_name

The same pattern, applied to a change of shape. Suppose GreenStore had been born with a single full_name column and you wanted to split it:

Phase Action
1 · Expand ALTER TABLE customers ADD COLUMN name VARCHAR(60), ADD COLUMN last_name VARCHAR(90);
2 · Double write The application fills in all three columns on every creation and every update
3 · Backfill An UPDATE splitting full_name at the first space (string functions: module 6)
4 · Switch the reads Forms and listings move to name and last_name; they're compared against full_name
5 · Contract SET NOT NULL on the two new ones and DROP COLUMN full_name

Notice what is never done: a RENAME COLUMN in one go, or a DROP COLUMN before nobody reads it. Every phase is reversible and none of them breaks the previous version of the code.

  1. Versioned migrations

Everything above has a prior requirement that isn't technical:

A schema change isn't a command: it's a versioned file in the repository.

What a versioned migration is

A numbered file, with a descriptive name, containing a schema change and (ideally) its reversal:

db/migrations/
├── V001__initial_schema.sql
├── V002__add_phone_customers.sql
├── V003__add_total_orders.sql
├── V004__backfill_total_orders.sql
└── V005__total_orders_not_null.sql

A migration tool keeps a control table inside the database itself with the migrations already applied, and on startup it applies only the missing ones, in order.

Why this is non-negotiable:

Without versioned migrations With versioned migrations
Nobody knows what schema each environment has The schema is reproducible from scratch
Changes don't go through code review Every change is a reviewable pull request
There's no history: who added that column and why? git log answers
Development, testing and production drift apart All three apply the same sequence
A deployment can forget the database change The deployment applies it automatically
Reproducing a bug is impossible Cloning the repository is enough

Common tools

Tool Ecosystem Format Reversal
Flyway Java, JVM, CLI Plain SQL or Java U (Teams) or manual
Liquibase Java, cross-platform XML, YAML, JSON or SQL Automatic in many cases
Alembic Python (SQLAlchemy) Python (upgrade/downgrade) Explicit, very widely used
Active Record Migrations Ruby on Rails Ruby (change or up/down) Automatic when it can be inferred
Django migrations Python (Django) Python autogenerated from the models Automatic for the most part
Laravel migrations PHP PHP (up/down) Explicit
Sqitch Agnostic, CLI Plain SQL (deploy/revert/verify) Explicit, with verification
golang-migrate / dbmate Go, agnostic Plain SQL (.up.sql / .down.sql) Explicit

They all solve the same problem and they differ mostly in whether the change is written in SQL or in the application's language. Writing it in plain SQL (Flyway, Sqitch, golang-migrate) gives you total control over lock_timeout, NOT VALID and CONCURRENTLY; writing it in the application's language (Alembic, Rails, Django) gives you portability between engines and automatic reversal, at the price of losing the fine-grained control large tables need.

The five golden rules

  1. One migration = one change. If the file does five things and the third fails in production, you won't know what state it's been left in (and in MySQL, on top of that, you won't be able to undo it).
  2. Always with a reversal script. Even if your engine has transactional DDL, writing the down forces you to think about whether the change is reversible. Very often you'll discover it isn't — a DROP COLUMN can't be undone — and that's invaluable information before applying it.
  3. Tested in an environment like production. Not on your laptop with 20 rows: on a copy with realistic volume. An ALTER COLUMN TYPE that takes 40 ms with 20 rows takes 40 minutes with 40 million.
  4. With a backup taken and verified beforehand. And verified means restored at some point, not "the cron job says it works".
  5. Never by hand in the production console. Not "just this once", not "it's a small change", not "it's urgent". A change that isn't in the repository doesn't exist, and the next environment that gets created won't have it.

And a corollary that comes out of expand/contract's phase 2: migrations breaking backward compatibility have to be split into several. V003 adds the column, V004 fills it, V005 makes it mandatory — each one deployable separately, and between them the code that needs them gets deployed.

  1. What to do and what not to do in production

What NOT to do

Why
ALTER TABLE by hand in the production console There's no record, it doesn't go through review and the next environment won't have it
DROP COLUMN on a column that "looks unused" If you're wrong, the data doesn't come back. First stop reading it for weeks, then delete it
RENAME COLUMN in one go It breaks all the code naming it the instant of the COMMIT
ALTER COLUMN TYPE on a large table during working hours It rewrites the table with an exclusive lock
ADD CONSTRAINT with no NOT VALID on a large table It scans the whole table with an exclusive lock
Migrating with no lock_timeout A 1 ms ALTER can queue the entire workload behind a long query
A migration file with fifteen changes If the eighth fails, good luck
Applying the migration and deploying the code at the same time During the deployment both versions coexist. That's why expand/contract exists

What TO do

Why
A versioned file, reviewed in a pull request History, review and reproducibility
A verified backup beforehand The only real safety net
Testing it on a copy with realistic volume Timings don't scale linearly in your intuition
SET LOCAL lock_timeout in every migration It turns an outage into a retry
NOT VALID + VALIDATE CONSTRAINT on large tables Immediate protection with no long lock
CREATE INDEX CONCURRENTLY (module 8) It builds the index without blocking writes
A backfill done in batches and idempotent Short transactions, rerunnable without harm
A maintenance window for anything that rewrites If something's going to take a while, let it take it when it doesn't hurt
Expand/contract for every incompatible change Zero downtime
Review by the database owner Somebody who knows the real volume, the load and the dependencies

The rule that sums up both tables: in production, the question isn't "does this ALTER TABLE work?", but "what happens while it's running, and what happens if it fails halfway?". If you can't answer both, the migration isn't ready.

Common Mistakes and Tips

  • Writing ALTER TABLE by hand in production. The root error from which almost all the others come.
  • Believing ADD COLUMN with a DEFAULT always rewrites the table. Since PostgreSQL 11 it doesn't, unless the DEFAULT is volatile.
  • Believing an "instantaneous" operation is harmless. It needs the ACCESS EXCLUSIVE, and if it can't get it, it queues the whole workload behind it. lock_timeout, always.
  • A RENAME in one go. It breaks the old code the instant of the COMMIT. Expand/contract, or don't do it.
  • ALTER COLUMN TYPE with no USING when the conversion isn't automatic. PostgreSQL's HINT tells you exactly what to write.
  • Narrowing a VARCHAR without checking the data. PostgreSQL aborts; MySQL in relaxed mode truncates silently.
  • ADD CONSTRAINT with no NOT VALID on large tables. A full scan with an exclusive lock.
  • Expecting a change of DEFAULT to update the existing rows. It doesn't: it only affects future insertions.
  • Using DROP COLUMN as a secure erase of sensitive data. The data is still on disk until the row is rewritten.
  • Assuming DDL is transactional everywhere. In PostgreSQL, SQL Server and SQLite it is; in MySQL and Oracle it isn't: a half-applied migration stays half-applied.
  • A backfill in a single giant transaction. It locks rows, it inflates the WAL and if it fails you have to start again. In batches.
  • A backfill that overwrites the double write. Add WHERE column IS NULL so you only touch what's missing.
  • Migrations with no reversal script. Writing it is the cheapest way of discovering the change isn't reversible.
  • Tip: \d table before and after every ALTER TABLE. Checking what you've done costs two seconds.
  • Tip: wrap migrations in BEGINCOMMIT if your engine allows it. In PostgreSQL, fifteen changes become atomic.
  • Tip: measure the time on a copy with real volume before touching production. It's the difference between a five-minute maintenance window and a five-hour one.

Exercises

Work on the freshly reloaded database, inside BEGINROLLBACK.

Exercise 1

GreenStore wants to record the channel each order comes in through (web or phone), something that until now was inferred indirectly from whether employee_id was NULL.

  1. Add a channel VARCHAR(10) column to orders, mandatory, with default value 'web' and protected by a named CHECK that only allows 'web' and 'phone'.
  2. Backfill it: the orders with a sales rep assigned are 'phone'; the rest, 'web'.
  3. Check the result by grouping by channel.
  4. Answer: why doesn't step 1 require rewriting the table? And what would have happened if you'd put the CHECK in before filling in the data?

Exercise 2

A colleague hands you this migration file to review before applying it to production, where products has 4 million rows and order_lines has 90 million:

-- ⚠️ V017__catalog_improvements.sql
ALTER TABLE products RENAME COLUMN name TO denomination;
ALTER TABLE products ADD COLUMN sku VARCHAR(20) NOT NULL DEFAULT gen_random_uuid()::text;
ALTER TABLE products ADD CONSTRAINT uq_products_sku UNIQUE (sku);
ALTER TABLE products ALTER COLUMN price TYPE NUMERIC(14,4);
ALTER TABLE order_lines ADD CONSTRAINT chk_ol_amount CHECK (quantity * unit_price >= 0);
ALTER TABLE products DROP COLUMN cost;
  1. Identify all the problems, saying for each one whether it's a locking, compatibility, reversibility or process problem.
  2. Estimate which of the six operations are instantaneous and which aren't.
  3. Rewrite the migration as it should be, splitting it into as many files as necessary.

Exercise 3

Apply the complete expand/contract pattern to add a total_spent NUMERIC(10,2) column to customers accumulating what each customer has bought (goods + shipping).

  1. Write the five phases, saying for each one what's a schema change, what's a code change and what's a data change.
  2. Run phases 1, 3 and 5 on GreenStore (2 and 4 belong to the application).
  3. Verify that the sum of every customer's total_spent matches the course's order total.
  4. Discuss: should the column be NOT NULL? What value do the three customers with no orders have, and what does that imply?

Solutions

Solution 1

BEGIN;

-- 1) Add the column with its DEFAULT and its CHECK, in a single statement
ALTER TABLE orders
    ADD COLUMN channel VARCHAR(10) NOT NULL DEFAULT 'web',
    ADD CONSTRAINT chk_orders_channel CHECK (channel IN ('web', 'phone'));
ALTER TABLE
-- 2) Backfill: the ones with a sales rep came in by phone
UPDATE orders
SET    channel = 'phone'
WHERE  employee_id IS NOT NULL
  AND  channel <> 'phone';
UPDATE 10
-- 3) The check
SELECT channel,
       COUNT(*)                      AS orders,
       COUNT(employee_id)            AS with_sales_rep,
       COUNT(*) - COUNT(employee_id) AS without_sales_rep,
       SUM(shipping_cost)            AS shipping
FROM   orders
GROUP BY channel
ORDER BY orders DESC, channel;
channel orders with_sales_rep without_sales_rep shipping
phone 10 10 0 72.15
web 10 0 10 46.10
COMMIT;

Ten and ten, exactly the split 01-06 described: half the channel is web. And 04-04's three forms of COUNT confirm the consistency: in the phone channel all 10 orders have a sales rep, in web none do. The shipping adds up to €72.15 + €46.10 = €118.25, module 4's canonical figure — and along the way it reveals something nobody had ever looked at: the phone channel pays considerably more shipping, because it concentrates the orders to Portugal and France.

4. The two questions.

Why it doesn't rewrite the table: the DEFAULT 'web' is a constant, and since PostgreSQL 11 that gets stored in the catalogue and returned on the fly for the old rows. If the default value had been something volatile, it would indeed have rewritten the 20 rows (irrelevant here, decisive with 20 million).

What would have happened with the CHECK before the data: in this particular case, nothing bad, because the DEFAULT 'web' leaves every row with a valid value. But if we'd added the column nullable and with no DEFAULT and then the CHECK, it wouldn't have failed either: the 20 rows would have NULL, and a CHECK evaluating to UNKNOWN is accepted (05-01, section 3.5). The CHECK would have gone through without complaining, over an empty column. It's the classic trap: order matters, but the CHECK doesn't always warn you that you've done things the wrong way round. The NOT NULL would have.

On a large table the correct, safe order would be: add the column nullable → backfill in batches → ADD CONSTRAINT ... NOT VALIDVALIDATE CONSTRAINTSET NOT NULL.

Solution 2

1 and 2. The problems, operation by operation:

# Operation Instantaneous Problems
1 RENAME COLUMN name TO denomination Yes Compatibility: it breaks all the code saying name the instant of the COMMIT. It needs expand/contract
2 ADD COLUMN sku ... DEFAULT gen_random_uuid()::text No Locking: a volatile DEFAULT → it rewrites 4 million rows with ACCESS EXCLUSIVE. On top of that, a UUID isn't an SKU: it's a meaningless identifier for a column that should have a business format
3 ADD CONSTRAINT uq_products_sku UNIQUE No Locking: it builds a unique index over 4 million rows with an exclusive lock. It should be CREATE UNIQUE INDEX CONCURRENTLY + ADD CONSTRAINT ... USING INDEX
4 ALTER COLUMN price TYPE NUMERIC(14,4) No Locking: changing a NUMERIC's scale is not binary-compatible → it rewrites the table and rebuilds indexes. And along the way it changes the money semantics of the whole system (01-04)
5 ADD CONSTRAINT chk_ol_amount with no NOT VALID No Locking: a full scan of 90 million rows with an exclusive lock. And the constraint is useless: quantity > 0 and unit_price >= 0 already guarantee it
6 DROP COLUMN cost Yes Reversibility: it destroys the data every business margin comes from, with no way back

And two process problems affecting the whole file:

  • Six heterogeneous changes in one migration. It violates the first golden rule: if the fourth fails, you're left halfway (in PostgreSQL the ROLLBACK saves you; in MySQL, it doesn't).
  • No lock_timeout. Any of these operations can queue the entire workload behind it.

Timing estimate at those volumes: operations 2, 3 and 4 are measured in minutes or hours each; number 5, in several minutes. The complete migration would leave the catalogue unreachable for all that time.

3. The correct version, split into files:

-- V017__add_sku_products.sql
-- EXPAND phase. Instantaneous: a nullable column, with no DEFAULT.
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE products ADD COLUMN sku VARCHAR(20);
COMMIT;
-- V018__backfill_sku_products.sql
-- In batches, idempotent. Run outside peak hours.
-- (The real SKU generator comes from the application; here, an example)
UPDATE products
SET    sku = 'GS-' || LPAD(id::text, 6, '0')
WHERE  sku IS NULL
  AND  id BETWEEN :from_id AND :to_id;
-- V019__sku_unique_and_not_null.sql
-- The index is built WITHOUT blocking writes (module 8).
CREATE UNIQUE INDEX CONCURRENTLY uq_products_sku_idx ON products (sku);

BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE products
    ADD CONSTRAINT uq_products_sku UNIQUE USING INDEX uq_products_sku_idx;
ALTER TABLE products ALTER COLUMN sku SET NOT NULL;
COMMIT;

And the three remaining operations:

Operation Verdict
RENAME COLUMN name TO denomination Rejected as it stands. If it really is needed, expand/contract across four migrations and several deployments. Ask first whether the new name adds anything
ALTER COLUMN price TYPE NUMERIC(14,4) Rejected. It changes the money semantics of the whole system. If more precision were needed for specific cases, that's a new column, not a type change
DROP COLUMN cost Rejected. First: check no query uses it, stop reading it for weeks, and only then propose the deletion in its own migration with a verified backup
ADD CONSTRAINT chk_ol_amount Rejected as unnecessary. quantity > 0 and unit_price >= 0 already guarantee it. And if it were still wanted, NOT VALID + VALIDATE

The exercise's lesson: most of a migration review consists of saying no. Of six operations, one is well conceived and five shouldn't be applied as they are.

Solution 3

1. The five phases:

Phase What it is Action
1 · Expand Schema ALTER TABLE customers ADD COLUMN total_spent NUMERIC(10,2); — nullable, instantaneous
2 · Double write Code The application adds the order's total to customers.total_spent when each purchase is confirmed
3 · Backfill Data Fill in backwards from the history, in batches, only where total_spent IS NULL
4 · Switch the reads Code The reports read the column; it's compared against the live calculation for a few days
5 · Contract Schema SET DEFAULT 0 and SET NOT NULL; the old calculation is removed from the code

2. Running phases 1, 3 and 5:

BEGIN;

-- PHASE 1 · EXPAND
ALTER TABLE customers ADD COLUMN total_spent NUMERIC(10,2);
ALTER TABLE

The temptation is to do it in one statement, joining customers, orders and order_lines:

-- ⚠️ INCORRECT: 04-04's mistake, section 11
SELECT o.customer_id,
       SUM(ol.quantity * ol.unit_price * (1 - ol.discount))
           + SUM(o.shipping_cost) AS total
FROM   order_lines AS ol
JOIN   orders      AS o ON ol.order_id = o.id
GROUP BY o.customer_id;

shipping_cost lives in orders, and after the JOIN each order appears as many times as it has lines: the shipping would be multiplied and the total sum would give €278.70 instead of €118.25. It's exactly the mistake module 3 warned about three times and module 4 settled.

The correct way is to aggregate in two stages, reusing section 10's order_totals table:

CREATE TEMP TABLE order_totals AS
SELECT ol.order_id,
       ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS goods
FROM   order_lines AS ol
GROUP BY ol.order_id;
SELECT 20
CREATE TEMP TABLE customer_spend AS
SELECT o.customer_id,
       ROUND(SUM(t.goods + o.shipping_cost), 2) AS total
FROM   orders AS o
JOIN   order_totals AS t ON t.order_id = o.id
GROUP BY o.customer_id;
SELECT 12
UPDATE customers AS c
SET    total_spent = s.total
FROM   customer_spend AS s
WHERE  s.customer_id = c.id
  AND  c.total_spent IS NULL;
UPDATE 12
SELECT c.id,
       c.name || ' ' || c.last_name AS customer,
       c.country,
       c.total_spent
FROM   customers AS c
ORDER BY c.total_spent DESC NULLS LAST, c.id;
id customer country total_spent
7 Sofia Moreira Costa Portugal 131.68
1 Lucía Martínez Soler Spain 112.55
9 Camille Dubois France 95.87
10 Julien Moreau France 79.40
4 Javier Ortega Ruiz Spain 72.83
6 Pau Llorens Vidal Spain 68.78
5 Ana Belmonte Roca Spain 64.75
2 Carlos Ferrer Ibáñez Spain 59.46
8 Tiago Almeida Nunes Portugal 54.50
12 Diego Ramos Herrera Spain 36.65
11 Elena Navarro Puig Spain 35.25
3 Marta Sanchis Gil Spain 34.48
13 Núria Bosch Ferrer Spain (null)
14 Hugo Iglesias Pardo Spain (null)
15 Inés Carrasco Vega Spain (null)

Sofia Moreira Costa tops the ranking with €131.68 across two orders, ahead of Lucía, who has placed three. And the last three, with NULL, are 01-06's customers with no orders.

3. Verification:

SELECT COUNT(*)                        AS customers,
       COUNT(total_spent)              AS with_purchases,
       COUNT(*) - COUNT(total_spent)   AS without_purchases,
       SUM(total_spent)                AS total
FROM   customers;
customers with_purchases without_purchases total
15 12 3 846.20

€846.20: the course's canonical figure (€727.95 of goods + €118.25 of shipping). Twelve customers with purchases and three with none, exactly 01-06's deliberate gaps. 04-04's three forms of COUNT do all the verification work again.

-- PHASE 5 · CONTRACT
ALTER TABLE customers
    ALTER COLUMN total_spent SET DEFAULT 0;

UPDATE customers SET total_spent = 0 WHERE total_spent IS NULL;
ALTER TABLE
UPDATE 3
ALTER TABLE customers
    ALTER COLUMN total_spent SET NOT NULL,
    ADD CONSTRAINT chk_customers_total_spent CHECK (total_spent >= 0);
ALTER TABLE
COMMIT;

4. The discussion. Should it be NOT NULL?

NULL for whoever hasn't bought 0 for whoever hasn't bought
Semantics "Has never bought" is distinguished from "bought and returned everything" The two cases get confused
Queries AVG(total_spent) ignores the three, giving the buyers' average AVG includes them and lowers the average
Sorting It needs an explicit NULLS LAST It sorts by itself
Arithmetic total_spent + 10 gives NULL It works
Fidelity to the model Greater Lesser

There's no universal answer, and that's the right answer. It depends on whether "hasn't bought" and "has bought €0" are the same business fact. In GreenStore they aren't: Núria, Hugo and Inés have never ordered anything, and that's information. If you want NOT NULL for arithmetic convenience, you have to document that the 0 means two things and add an orders_placed INTEGER NOT NULL DEFAULT 0 column that does distinguish them.

It's exactly 04-03's discussion, section 11 —designing with NULL, with sentinels or with NOT NULL— and the proof that a schema decision is never purely technical.

And the open problem, the same one as with orders.total: this column is a precomputed aggregate and it has to be kept in sync. Every new order, every modified line and every return leaves it stale. The solution —a trigger, or a materialized view that gets refreshed— belongs to module 10. Until it exists, the column is a time bomb, and that's why phase 2 of the pattern (the double write) isn't optional: it's the only thing stopping the data from rotting.

Module conclusion

ALTER TABLE closes module 5 and with it your ability to operate on a complete database:

  • The ALTER TABLE operations: ADD COLUMN (instantaneous, even with a constant DEFAULT since PostgreSQL 11, unless it's volatile), DROP COLUMN (instantaneous, irreversible and not a secure erase), RENAME (instantaneous and the most dangerous, because it breaks the old code on the spot), ALTER COLUMN TYPE with USING for the non-trivial conversions, SET/DROP NOT NULL, SET/DROP DEFAULT —which doesn't touch the existing rows—, and ADD/DROP CONSTRAINT, where 05-01's insistence on naming them pays off.
  • NOT VALID + VALIDATE CONSTRAINT: immediate protection for the new rows and a check on the history without blocking reads or writes. The standard technique on large tables.
  • What locks and what doesn't, with the table of levels; and the detail that really takes production down: an instantaneous operation that can't get the ACCESS EXCLUSIVE queues the entire workload behind it. SET LOCAL lock_timeout is the protection.
  • Transactional DDL: in PostgreSQL, SQL Server and SQLite an ALTER TABLE can be undone with ROLLBACK and a fifteen-step migration is atomic; in MySQL and Oracle it can't, and a migration that fails halfway stays halfway.
  • Expand/contract: add the new thing → write to both → fill in backwards → switch the reads → remove the old one. Five phases in which no state ever exists that breaks the application. Demonstrated with orders.total, whose idempotent backfill (WHERE total IS NULL) added up to the course's canonical €846.20.
  • Versioned migrations: one numbered file per change, in the repository, reviewed as code, applied by a tool (Flyway, Liquibase, Alembic, Rails, Django, Laravel, Sqitch). And the five golden rules: one migration = one change, always with a reversal, tested with realistic volume, with a verified backup, and never by hand in production.

And with that, module 5 closes. Look back at what you've gained in six lessons. You know how to create tables with CREATE TABLE and declare the six constraints you'd spent five modules reading, understanding why CHECK accepts nulls, why UNIQUE allows several of them and why GENERATED BY DEFAULT is what forces that setval block. You know how to insert with INSERT, always listing the columns, in batches, and using RETURNING to retrieve the id you need to hang an order's lines off. You know how to modify with UPDATE, with a five-step protocol and an open transaction, understanding that all the assignments are evaluated over the old row and that price * 1.05 isn't idempotent. You know how to delete with DELETE, how to tell it apart from TRUNCATE, how to predict what each ON DELETE takes with it, and —most importantly— how to decide whether anything should be deleted at all, because active = FALSE exists precisely so that it isn't. You know how to merge with ON CONFLICT and with MERGE, and why look-then-decide is never safe. And you know how to change the schema without taking the service down.

Something more than the repertoire of statements has changed: the level of responsibility has changed. In modules 2 to 4, a mistake of yours returned a wrong number. From this module on, a mistake of yours modifies real data. That's why here you've learned as many habits as syntax: write the SELECT before the UPDATE, count the rows, open the transaction, check the RETURNING, take a backup first, set lock_timeout, version the migration. None of those habits appears in the language reference, and all of them separate someone who has spent years touching databases from someone who has spent a month.

With GreenStore you can now do everything: query it, join it, aggregate it, populate it, correct it and make it evolve. What you still can't do is transform and present what you get out of it. You're still returning name and last_name in two columns when you want one; you're still writing ROUND(...) by hand without knowing its siblings; you still can't extract the year from a date, work out how long a customer has been with you, put a piece of text in upper case, convert one type into another explicitly, replace a NULL with something readable, or classify rows into categories according to a condition. In module 6, Functions, the missing tools arrive: string functions to compose and clean up text, numeric functions to round and compute with judgement, date and time functions to answer at last "how long did that order take?", CAST and COALESCE to convert types and tame the nulls you've spent two modules dodging, and CASE to put conditional logic inside a query. From then on, your queries will stop returning data and start returning answers.

SQL Course

Module 1: Introduction to SQL

Module 2: Basic SQL queries

Module 3: Working with multiple tables

Module 4: Advanced data filtering

Module 5: Data manipulation

Module 6: Advanced SQL functions

Module 7: Subqueries and nested queries

Module 8: Indexes and performance tuning

Module 9: Transactions and concurrency

Module 10: Advanced topics

Module 11: SQL in practice

Module 12: Final project

© Copyright 2026. All rights reserved