This tutorial starts with a simple v1 schema and evolves it through
several migrations: adding columns, renaming fields, changing types
with CAST/::, and wrapping everything in
transactions. This is the “my schema needs to change”
workflow.
A minimal user table to start with.
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
active INT DEFAULT 1
);
INSERT INTO users (name, email, active) VALUES
('Alice', 'alice@example.com', 1),
('Bob', 'bob@example.com', 1),
('Carol', 'carol@example.com', 0),
('Dave', 'dave@example.com', 1);
SELECT * FROM users
ORDER BY id;
Result
| id | name | active | |
|---|---|---|---|
| 1 | Alice | alice@example.com | 1 |
| 2 | Bob | bob@example.com | 1 |
| 3 | Carol | carol@example.com | 0 |
| 4 | Dave | dave@example.com | 1 |
Add a created_at column. Existing rows get NULL
since there is no default.
ALTER TABLE users ADD COLUMN created_at DATE;
SELECT id, name, created_at
FROM users ORDER BY id;
Result
| id | name | created_at |
|---|---|---|
| 1 | Alice | NULL |
| 2 | Bob | NULL |
| 3 | Carol | NULL |
| 4 | Dave | NULL |
Backfill the new column with a default date.
UPDATE users SET created_at = '2025-01-01';
Rename name to display_name for clarity.
ALTER TABLE users RENAME COLUMN name TO display_name;
SELECT id, display_name, email
FROM users ORDER BY id;
Result
| id | display_name | |
|---|---|---|
| 1 | Alice | alice@example.com |
| 2 | Bob | bob@example.com |
| 3 | Carol | carol@example.com |
| 4 | Dave | dave@example.com |
The active column is an INT but should be
BOOLEAN. Use ALTER COLUMN TYPE to convert it.
ALTER TABLE users ALTER COLUMN active TYPE BOOLEAN;
SELECT id, display_name,
active
FROM users ORDER BY id;
Result
| id | display_name | active |
|---|---|---|
| 1 | Alice | true |
| 2 | Bob | true |
| 3 | Carol | false |
| 4 | Dave | true |
The CAST() function and the :: postfix operator
both convert between types. They are interchangeable.
SELECT display_name,
active::INT AS active_int,
CAST(id AS TEXT)
|| '-' || display_name
AS code
FROM users
ORDER BY id;
Result
| display_name | active_int | code |
|---|---|---|
| Alice | 1 | 1-Alice |
| Bob | 1 | 2-Bob |
| Carol | 0 | 3-Carol |
| Dave | 1 | 4-Dave |
Wrap a multi-step migration in a transaction. If any step fails, all changes are rolled back.
BEGIN;
ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'user';
UPDATE users SET role = 'admin'
WHERE display_name = 'Alice';
COMMIT;
SELECT display_name, role
FROM users ORDER BY id;
Result
| display_name | role |
|---|---|
| Alice | admin |
| Bob | user |
| Carol | user |
| Dave | user |
mskql supports nested BEGIN blocks. The inner transaction
can be rolled back independently while the outer one continues.
BEGIN;
UPDATE users SET role = 'moderator'
WHERE display_name = 'Bob';
BEGIN;
UPDATE users SET role = 'moderator'
WHERE display_name = 'Carol';
ROLLBACK;
COMMIT;
SELECT display_name, role
FROM users ORDER BY id;
Result
| display_name | role |
|---|---|
| Alice | admin |
| Bob | moderator |
| Carol | user |
| Dave | user |
Bob’s role was updated in the outer transaction (committed). Carol’s update was in the inner transaction (rolled back), so she stays as “user”.
Remove the old active column now that we have role.
ALTER TABLE users DROP COLUMN active;
SELECT * FROM users
ORDER BY id;
Result
| id | display_name | created_at | role | |
|---|---|---|---|---|
| 1 | Alice | alice@example.com | 2025-01-01 | admin |
| 2 | Bob | bob@example.com | 2025-01-01 | moderator |
| 3 | Carol | carol@example.com | 2025-01-01 | user |
| 4 | Dave | dave@example.com | 2025-01-01 | user |
ALTER TABLE ADD COLUMN to extend a tableALTER TABLE RENAME COLUMN to rename fieldsALTER TABLE ALTER COLUMN TYPE to change column typesALTER TABLE DROP COLUMN to remove columnsCAST() and :: for type conversions in queriesBEGIN / COMMIT / ROLLBACK for transactional migrationsBEGIN for partial rollback within a larger transaction