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 TABLEis a destructive operation, irreversible in its effects on the data. ADROP COLUMNremoves an entire column and all its contents; anALTER COLUMN TYPEcan truncate values; anADD CONSTRAINTcan 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
BEGIN…ROLLBACK: 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
ALTER TABLE: the map of operationsADD COLUMN, with and without aDEFAULTDROP COLUMNRENAME COLUMNandRENAME TOALTER COLUMN TYPEand theUSINGclauseSET/DROP NOT NULLandSET/DROP DEFAULTADD/DROP CONSTRAINT, and theNOT VALID+VALIDATEtechnique- What locks the table and what doesn't
- Transactional DDL: PostgreSQL against MySQL
- The expand/contract pattern
- Versioned migrations
- What to do and what not to do in production
- Common Mistakes and Tips
- Exercises
- Module conclusion
ALTER TABLE: the map of operations
ALTER TABLE: the map of operationsThe 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; 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 | falseA warning about this lesson's objects.
customers.phone,customers.newsletterandorders.totalare one-off examples: they aren't part of GreenStore's canonical schema and they won't appear in the following modules. Reloadgreenstore.sqlwhen you're done.
ADD COLUMN, with and without a DEFAULT
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
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.
| 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
| 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
DEFAULTno 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 COLUMNwithALGORITHM=INSTANTin 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-constantDEFAULT. SQL Server distinguishes between adding a nullable column (instantaneous) and aNOT NULLone with aDEFAULT(instantaneous since 2012 Enterprise, a rewrite in the other editions).
DROP COLUMN
DROP COLUMNThis 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:
- The data is still physically on disk until the row is rewritten. If the column contained sensitive information, a
DROP COLUMNis not a secure erase. - 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:
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:
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.
RENAME COLUMN and RENAME TO
RENAME COLUMN and RENAME TOBoth 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:
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
RENAMEin 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 COLUMN TYPE and the USING clause
ALTER COLUMN TYPE and the USING clauseWidening 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:
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. Withsql_modeatSTRICT_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');A direct attempt to convert the text into a 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);| 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 |
SET / DROP NOT NULL and SET / DROP DEFAULT
SET / DROP NOT NULL and SET / DROP DEFAULTIt 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:
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:
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;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.
ADD / DROP CONSTRAINT, and the NOT VALID + VALIDATE technique
ADD / DROP CONSTRAINT, and the NOT VALID + VALIDATE techniqueHere 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.
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:
Dropping it:
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);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;-- Stage 2: validate. It may take a while, but it does NOT block reads or writes.
ALTER TABLE products VALIDATE CONSTRAINT chk_products_margin;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:
- 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:
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.
- 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 |
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 BEGIN … COMMIT 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.
- 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.
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;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;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 batches —WHERE 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.
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.
- 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
- 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).
- 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 COLUMNcan't be undone — and that's invaluable information before applying it. - Tested in an environment like production. Not on your laptop with 20 rows: on a copy with realistic volume. An
ALTER COLUMN TYPEthat takes 40 ms with 20 rows takes 40 minutes with 40 million. - With a backup taken and verified beforehand. And verified means restored at some point, not "the cron job says it works".
- 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.
- 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 TABLEwork?", 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 TABLEby hand in production. The root error from which almost all the others come. - Believing
ADD COLUMNwith aDEFAULTalways rewrites the table. Since PostgreSQL 11 it doesn't, unless theDEFAULTis 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
RENAMEin one go. It breaks the old code the instant of theCOMMIT. Expand/contract, or don't do it. ALTER COLUMN TYPEwith noUSINGwhen the conversion isn't automatic. PostgreSQL'sHINTtells you exactly what to write.- Narrowing a
VARCHARwithout checking the data. PostgreSQL aborts; MySQL in relaxed mode truncates silently. ADD CONSTRAINTwith noNOT VALIDon large tables. A full scan with an exclusive lock.- Expecting a change of
DEFAULTto update the existing rows. It doesn't: it only affects future insertions. - Using
DROP COLUMNas 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 NULLso 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 tablebefore and after everyALTER TABLE. Checking what you've done costs two seconds. - Tip: wrap migrations in
BEGIN…COMMITif 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 BEGIN … ROLLBACK.
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.
- Add a
channel VARCHAR(10)column toorders, mandatory, with default value'web'and protected by a namedCHECKthat only allows'web'and'phone'. - Backfill it: the orders with a sales rep assigned are
'phone'; the rest,'web'. - Check the result by grouping by channel.
- Answer: why doesn't step 1 require rewriting the table? And what would have happened if you'd put the
CHECKin 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;- Identify all the problems, saying for each one whether it's a locking, compatibility, reversibility or process problem.
- Estimate which of the six operations are instantaneous and which aren't.
- 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).
- Write the five phases, saying for each one what's a schema change, what's a code change and what's a data change.
- Run phases 1, 3 and 5 on GreenStore (2 and 4 belong to the application).
- Verify that the sum of every customer's
total_spentmatches the course's order total. - 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'));-- 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';-- 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 |
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 VALID → VALIDATE CONSTRAINT → SET 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
ROLLBACKsaves 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:
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;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;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;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 customers
ALTER COLUMN total_spent SET NOT NULL,
ADD CONSTRAINT chk_customers_total_spent CHECK (total_spent >= 0);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 TABLEoperations:ADD COLUMN(instantaneous, even with a constantDEFAULTsince 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 TYPEwithUSINGfor the non-trivial conversions,SET/DROP NOT NULL,SET/DROP DEFAULT—which doesn't touch the existing rows—, andADD/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 EXCLUSIVEqueues the entire workload behind it.SET LOCAL lock_timeoutis the protection. - Transactional DDL: in PostgreSQL, SQL Server and SQLite an
ALTER TABLEcan be undone withROLLBACKand 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
- What is SQL?
- Setting up your SQL environment
- Basic SQL syntax
- Understanding databases and tables
- The relational model: primary and foreign keys
- The course database: GreenStore
Module 2: Basic SQL queries
- The SELECT statement
- Aliases, expressions and calculated columns
- Filtering data with WHERE
- DISTINCT and removing duplicates
- Sorting data with ORDER BY
- Limiting results with LIMIT
Module 3: Working with multiple tables
- JOIN operations
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN and CROSS JOIN
- Set operations: UNION, INTERSECT and EXCEPT
Module 4: Advanced data filtering
- Using LIKE for pattern matching
- The IN and BETWEEN operators
- NULL values and IS NULL
- Aggregate functions: COUNT, SUM, AVG, MIN and MAX
- Aggregating data with GROUP BY
- The HAVING clause
Module 5: Data manipulation
- Creating tables and constraints with CREATE TABLE
- The INSERT statement
- The UPDATE statement
- The DELETE statement
- The UPSERT (MERGE) statement
- Changing the schema: ALTER TABLE and safe migrations
Module 6: Advanced SQL functions
- String functions
- Numeric functions
- Date and time functions
- Type conversion and handling NULL: CAST and COALESCE
- Conditional expressions
Module 7: Subqueries and nested queries
- Introduction to subqueries
- Correlated subqueries
- EXISTS and NOT EXISTS
- Using subqueries in SELECT, FROM and WHERE
- Subquery or JOIN: which one to choose
Module 8: Indexes and performance tuning
- Understanding indexes
- Creating and managing indexes
- Index types and when not to index
- Query optimization techniques
- Analyzing query performance
Module 9: Transactions and concurrency
- Introduction to transactions
- ACID properties
- Transaction control statements
- Isolation levels and concurrency anomalies
- Handling concurrency: locks and deadlocks
Module 10: Advanced topics
- Views
- Common table expressions (CTEs)
- Window functions
- Stored procedures
- Triggers
- JSON and semi-structured data
Module 11: SQL in practice
- Real-world use cases
- Best practices
- Security: SQL injection, permissions and roles
- SQL for data analysis
- SQL in web development
