SQL (PostgreSQL, MySQL, SQLite) Module 9 – Creating tables and constraints
ALTER TABLE and safe schema migrations
Change tables with ALTER TABLE in PostgreSQL, MySQL and SQLite, rebuild SQLite tables safely, and ship schema changes without downtime by expand and contract.
What you will learn
- Add, rename and drop columns and constraints with ALTER TABLE
- Explain the limits of ALTER TABLE in SQLite and rebuild a table safely
- Predict how schema changes behave inside a transaction in each engine
- Plan a change without downtime with expand and contract and batched backfills
Before you start
What you’ll learn · Before you start
What you will learn
- Add, rename and drop columns and constraints with ALTER TABLE
- Explain the limits of ALTER TABLE in SQLite and rebuild a table safely
- Predict how schema changes behave inside a transaction in each engine
- Plan a change without downtime with expand and contract and batched backfills
Before you start
On this page
ALTER TABLE changes a table that already exists: it adds, renames and drops columns, adds and removes constraints, and changes a column’s type. On a laptop each change takes a moment. On a production table that thousands of requests read every second, the same statement can lock the table, rewrite every row, or break the version of the application that is still running. Safe schema migrations avoid that with three habits: every change is a versioned file that is applied once, in order; each step leaves a schema that both the old and the new code can use; and slow work, such as filling a new column, happens in small batches. This lesson shows what ALTER TABLE can do in each engine, where SQLite needs a table rebuild instead, how each engine treats schema changes inside a transaction, and the expand-and-contract plan that makes changes without downtime. Its examples use SQLite 3.49.1 as packaged in sql.js 1.14.2, so each one runs right here.
What ALTER TABLE can do in each engine
-- The four changes SQLite 3.49.1 can make in place, and four it refuses.
CREATE TABLE customers (customer_id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT, fax TEXT);
INSERT INTO customers (name, city) VALUES ('Asha Kulkarni', 'Pune'), ('Vikram Nair', 'Kochi');
ALTER TABLE customers ADD COLUMN phone TEXT; -- a new, empty column
ALTER TABLE customers ADD COLUMN tier TEXT NOT NULL DEFAULT 'basic'; -- required, so it needs a default
ALTER TABLE customers RENAME COLUMN city TO town;
ALTER TABLE customers DROP COLUMN fax;
ALTER TABLE customers RENAME TO clients;
ALTER TABLE clients ADD COLUMN state_code TEXT NOT NULL; -- refused: required without a default
ALTER TABLE clients ADD COLUMN joined_at TEXT DEFAULT CURRENT_TIMESTAMP; -- refused: the default is not constant
ALTER TABLE clients ADD COLUMN email TEXT UNIQUE; -- refused: UNIQUE needs a new table
ALTER TABLE clients ALTER COLUMN phone SET NOT NULL; -- no ALTER COLUMN before SQLite 3.53.0
SELECT * FROM clients;
SELECT name, type, "notnull", dflt_value FROM pragma_table_info('clients'); Output (exit status 1)
┌─────────────┬───────────────┬───────┬───────┬───────┐ │ customer_id │ name │ town │ phone │ tier │ ├─────────────┼───────────────┼───────┼───────┼───────┤ │ 1 │ Asha Kulkarni │ Pune │ │ basic │ │ 2 │ Vikram Nair │ Kochi │ │ basic │ └─────────────┴───────────────┴───────┴───────┴───────┘ ┌─────────────┬─────────┬─────────┬────────────┐ │ name │ type │ notnull │ dflt_value │ ├─────────────┼─────────┼─────────┼────────────┤ │ customer_id │ INTEGER │ 0 │ │ │ name │ TEXT │ 1 │ │ │ town │ TEXT │ 0 │ │ │ phone │ TEXT │ 0 │ │ │ tier │ TEXT │ 1 │ 'basic' │ └─────────────┴─────────┴─────────┴────────────┘
Printed as an error (standard error)
Runtime error near line 11: Cannot add a NOT NULL column with default value NULL Runtime error near line 12: Cannot add a column with non-constant default Parse error near line 13: Cannot add a UNIQUE column Parse error near line 14: near "ALTER": syntax error
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < alter_basics.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
SQLite added two columns, renamed a column and the table, and dropped a column. It refused a required column without
a default, a default that is not constant, a UNIQUE column, and ALTER COLUMN, which SQLite added only in version
3.53.0, for SET NOT NULL and DROP NOT NULL. Everything SQLite cannot change in place is done by building a new
table and copying the rows across, a procedure of twelve steps that the SQLite manual spells out. PostgreSQL and MySQL
change most of these in place:
| Criterion | SQLite 3.49 | PostgreSQL 18 | MySQL 8.4 |
|---|---|---|---|
| ADD COLUMN, RENAME COLUMN, DROP COLUMN, RENAME TO | Yes, with limits on what a new column may declare | Yes | Yes |
| Add a NOT NULL column to a table with rows | Only with a constant default | Yes; with a default, existing rows get it | Yes; with a default, existing rows get it |
| Add or drop a UNIQUE, CHECK or FOREIGN KEY constraint | No: rebuild the table | Yes: ADD CONSTRAINT and DROP CONSTRAINT | Yes, as ADD CONSTRAINT or ADD INDEX |
| Change a column type or NOT NULL | No: rebuild the table (NOT NULL from 3.53.0) | Yes: ALTER COLUMN | Yes: MODIFY COLUMN |
| Schema changes inside a transaction | Rolled back with it | Rolled back with it | Each one commits at once |
Schema changes and transactions
-- In SQLite, as in PostgreSQL, schema changes are part of the transaction.
CREATE TABLE customers (customer_id INTEGER PRIMARY KEY, name TEXT NOT NULL);
BEGIN;
ALTER TABLE customers ADD COLUMN loyalty_points INTEGER NOT NULL DEFAULT 0;
CREATE TABLE loyalty_events (event_id INTEGER PRIMARY KEY, customer_id INTEGER, points INTEGER);
INSERT INTO customers (name) VALUES ('Asha Kulkarni');
ROLLBACK; -- the migration failed a check later on: undo all of it
SELECT name FROM sqlite_schema WHERE type = 'table';
SELECT name FROM pragma_table_info('customers');
SELECT count(*) AS customers FROM customers; Output
┌───────────┐ │ name │ ├───────────┤ │ customers │ └───────────┘ ┌─────────────┐ │ name │ ├─────────────┤ │ customer_id │ │ name │ └─────────────┘ ┌───────────┐ │ customers │ ├───────────┤ │ 0 │ └───────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < ddl_rollback.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
The rollback took back the new column, the new table and the inserted row together, so the database looked as if
the migration had never started. PostgreSQL behaves the same way, which lets a migration run as one transaction that
either finishes or leaves nothing behind; on PostgreSQL 18.6 a rolled-back ADD COLUMN and CREATE TABLE left no
trace. MySQL does not: a CREATE TABLE or an ALTER TABLE commits the open transaction first. On MySQL 8.4.11 an
INSERT made before a CREATE TABLE survived the ROLLBACK, and so did an INSERT made after it, because once the
transaction has been committed the session is back in autocommit mode. A MySQL migration that fails halfway therefore
stays half applied, which is why such migrations make one schema change per step.
Safe schema migrations
Migration tools differ, but safe schema migrations share one shape. Each change is a numbered file, such as
0042_add_state_code.sql, kept in the same repository as the code. The database records which files it has applied,
in a table of its own or, in SQLite, in PRAGMA user_version, so every environment applies the same files in the
same order exactly once. A change that the running code cannot survive, such as a rename, is split into steps that
each keep both versions of the code working:
Renaming a column in five safe steps
Text description of the diagram
The diagram shows how to rename the column city of customers to town without stopping the application, from top to bottom. Each step is a separate migration or deploy.
- Expand: add the new column town, and copy every new write of city into it, for example with a trigger. The code that is running still reads and writes city, so it keeps working.
- Backfill: fill town for the rows that existed before, in small batches. Before going on, check that no row has a town that differs from its city.
- Switch reads: deploy code that reads town. Both the old and the new code keep working, because both columns hold the same values.
- Switch writes: deploy code that writes only town.
- Contract: when nothing reads or writes city any more, drop the copying and then the column city.
Each step can be deployed and, if something goes wrong, reversed on its own. The cost is time: a rename becomes five releases instead of one, and the database holds both columns for a while.
Two more rules keep the history of migrations trustworthy. Never edit a migration that has run anywhere, even on a colleague’s laptop: correct it with a new one, or the environments drift apart without anyone noticing. And write the reverse of each step where one exists, while treating the steps that destroy data, such as dropping a column, as one-way: they run last, after a backup, when nothing reads the old data any more.
Your turn
Exercise · Medium · SQL
A migration that adds a required column
Each test creates a table customers (customer_id, name, city) with some rows, and a lookup table city_states (city, state_code) that gives the two-letter state code of every city in it.
Write a migration for SQLite that adds a column state_code to customers:
- it is required: SQLite must record it as
NOT NULL; - every existing customer gets the state code of their city from
city_states; - a customer whose city is not in
city_states, or has no city, gets'XX', so that someone can fix it later.
Leave the last statement of the starter code as it is: it returns every customer with their state code and, from SQLite's catalog, whether the column is NOT NULL (1) or not (0).
Starter code · migration.sql
-- The migration: add the required column state_code and fill it in for every existing customer.
-- Leave this as it is: every customer, their state code, and whether SQLite records the column as NOT NULL.
SELECT customer_id, state_code,
(SELECT "notnull" FROM pragma_table_info('customers') WHERE name = 'state_code') AS required
FROM customers
ORDER BY customer_id; The sample tests · tests.yaml
# Sample tests: each one creates customers and city_states, runs your migration and compares the rows it returns, in
# order.
tests:
- name: fills the state code from the city, and marks the column NOT NULL
ordered: true
setup: |
CREATE TABLE customers (customer_id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT);
CREATE TABLE city_states (city TEXT PRIMARY KEY, state_code TEXT NOT NULL);
INSERT INTO customers VALUES (1, 'Asha Kulkarni', 'Pune'), (2, 'Vikram Nair', 'Kochi'), (3, 'Meera Rathore', 'Jaipur');
INSERT INTO city_states VALUES ('Pune', 'MH'), ('Kochi', 'KL'), ('Jaipur', 'RJ');
expected:
columns: [customer_id, state_code, required]
rows:
- [1, 'MH', 1]
- [2, 'KL', 1]
- [3, 'RJ', 1]
- name: gives XX to an unknown city and to a missing one
ordered: true
setup: |
CREATE TABLE customers (customer_id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT);
CREATE TABLE city_states (city TEXT PRIMARY KEY, state_code TEXT NOT NULL);
INSERT INTO customers VALUES (1, 'Tenzin Dorjee', 'Gangtok'), (2, 'Ira Sen', 'Atlantis'), (3, 'Kabir Das', NULL);
INSERT INTO city_states VALUES ('Gangtok', 'SK'), ('Pune', 'MH');
expected:
columns: [customer_id, state_code, required]
rows:
- [1, 'SK', 1]
- [2, 'XX', 1]
- [3, 'XX', 1] A hint
SQLite adds a NOT NULL column only together with a non-NULL default, so the ADD COLUMN can give 'XX' as the default. Then an UPDATE with a subquery on city_states replaces it wherever the city is known.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
3 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
The SQLite table rebuild, step by step, and the shortcut that breaks it Pro: opens with a Pro, Premium or Ultimate pass
SQLite changes everything beyond its few in-place forms by building a new table and moving the data across. The order of the steps matters, and the obvious shortcut quietly breaks the foreign keys of other tables.
In this part:
- Twelve steps, run on a real table
- Renaming the old table first, the failure reproduced
- Recording the version in the same transaction
- What one rebuild costs
3 runnable examples (SQL) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Changes without downtime: locks, costs and expand and contract, with numbers Pro: opens with a Pro, Premium or Ultimate pass
On a busy server the danger of a schema change is rarely the change itself; it is the lock it waits for, the rows it rewrites, and the code that still expects the old schema. This part measures each and works through a rename from start to finish.
In this part:
- The lock queue, reproduced
- What each change costs
- Expand and contract, worked through
2 runnable examples (Python, SQL) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Mid to senior
You must rename
orders.amounttoamount_paisein a PostgreSQL database that serves traffic day and night, with several application servers that are deployed one by one. Plan the change so that no request fails at any point. -
Mid to senior
A migration that only added a nullable column to a busy PostgreSQL table took the site down for a minute: every query on that table hung until the migration finished. Adding such a column rewrites nothing, so what happened, and how do you run migrations like it safely?
-
Mid to senior
A MySQL migration file adds a column, fills it with an UPDATE, then creates a unique index that fails on a duplicate. The tool ran the file in a transaction and rolled back on the error, yet the column exists and is filled. Why, and how should MySQL migrations be written instead?
Key takeaways
- ALTER TABLE adds, renames and drops columns everywhere, and adds constraints and changes types in PostgreSQL and MySQL; SQLite rebuilds the table for most other changes.
- Schema changes are transactional in PostgreSQL and SQLite, while in MySQL each one commits at once.
- Safe schema migrations are versioned files applied once, in order, whose every step works with the old and the new code.
- Expand and contract turns a breaking change, such as a rename, into steps that can each be deployed and undone.
References
- PostgreSQL 18: ALTER TABLE (The PostgreSQL Global Development Group)
- PostgreSQL 18: Modifying Tables (The PostgreSQL Global Development Group)
- PostgreSQL 18: Explicit Locking (table-level locks) (The PostgreSQL Global Development Group)
- PostgreSQL 18: Client Connection Defaults (lock_timeout) (The PostgreSQL Global Development Group)
- PostgreSQL 18: CREATE INDEX (building indexes concurrently) (The PostgreSQL Global Development Group)
- SQLite: ALTER TABLE (SQLite)
- SQLite: PRAGMA statements (user_version, foreign_key_check) (SQLite)
- MySQL 8.4 Reference Manual: ALTER TABLE Statement (Oracle Corporation)
- MySQL 8.4 Reference Manual: Online DDL Operations (Oracle Corporation)
- MySQL 8.4 Reference Manual: Statements That Cause an Implicit Commit (Oracle Corporation)
- MySQL 8.4 Reference Manual: Metadata Locking (Oracle Corporation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress