Skip to main content

Migrations

pgStack uses a plain-SQL migration system. Migration files are .sql files prefixed with a YYYYMMDDHHMMSS timestamp (e.g. 20250114120000_add_products_table.sql) and applied in filename sort order; the scaffolded bootstrap migration 00000000000000_pgstack_init.sql sorts first.

Commands

Create a migration

pgstack migration create <name>

Creates a new timestamped migration file in the migrations/ directory:

pgstack migration create add_products_table
# Creates: migrations/20250114120000_add_products_table.sql

The generated file contains a template:

-- Migration: add_products_table
-- Created at: 2025-01-14T12:00:00.000Z

-- migrate:up


-- migrate:down

SQL under -- migrate:up is applied by pgstack migration run; SQL under -- migrate:down is executed on rollback.

Run migrations

pgstack migration run

Applies all pending migrations in filename sort order:

+ 20250114120000_add_products_table
+ 20250114120500_add_sku_to_products
Applied 2 migration(s).

Migrations are tracked in a pgstack.migrations table (schema pgstack, table migrations) created automatically.

Migration status

pgstack migration status

Shows all migrations and their status:

Migration Status:

v 00000000000000_pgstack_init applied 1/14/2025, 12:00:00 PM
v 20250114120000_add_products_table applied 1/14/2025, 12:05:00 PM
o 20250114121000_add_orders_table pending

Roll back

pgstack migration rollback

Options:

  • -n, --count <n> — number of migrations to roll back (default: 1)
  • --dry-run — print the down SQL that would be executed without running it

Rolls back the most recently applied migration(s) by executing the SQL in the -- migrate:down section of the same migration file, then deleting its row from pgstack.migrations. If the migration file is missing or has no -- migrate:down section, that migration is skipped with a warning — the command does not fail.

Roll back several migrations at once, or preview the SQL first:

pgstack migration rollback -n 2 # roll back the last 2 migrations
pgstack migration rollback --dry-run # print the rollback SQL without executing

Migration file conventions

Name migrations descriptively:

00000000000000_pgstack_init.sql # scaffolded bootstrap (sorts first)
20250114120000_add_products_table.sql # new table
20250114120500_add_category_to_products.sql # alter table
20250114121000_add_product_search_index.sql # index
20250114121500_seed_categories.sql # data migration

Example migrations

Create a table

-- 20250114120000_add_products_table.sql

-- migrate:up
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
price NUMERIC(10, 2) NOT NULL CHECK (price >= 0),
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_products_active ON products(active) WHERE active = true;

ALTER TABLE products ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Public can read active products"
ON products FOR SELECT
USING (active = true);

CREATE POLICY "Service role full access"
ON products FOR ALL
USING (auth.role() = 'service_role');

GRANT SELECT ON products TO anon, authenticated;
GRANT INSERT, UPDATE, DELETE ON products TO service_role;
GRANT USAGE, SELECT ON SEQUENCE products_id_seq TO service_role;

-- migrate:down
DROP TABLE IF EXISTS products;

Add a column

-- 20250114120500_add_sku_to_products.sql

-- migrate:up
ALTER TABLE products ADD COLUMN sku TEXT UNIQUE;
CREATE INDEX idx_products_sku ON products(sku) WHERE sku IS NOT NULL;

-- migrate:down
ALTER TABLE products DROP COLUMN IF EXISTS sku;

Add a foreign key

-- 20250114121000_add_orders_table.sql

-- migrate:up
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES pgstack.users(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'processing', 'shipped', 'delivered', 'cancelled')),
amount NUMERIC(10, 2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users see own orders" ON orders
FOR SELECT USING (user_id = auth.uid());

CREATE POLICY "Users insert own orders" ON orders
FOR INSERT WITH CHECK (user_id = auth.uid());

GRANT SELECT, INSERT ON orders TO authenticated;
GRANT USAGE, SELECT ON SEQUENCE orders_id_seq TO authenticated;

-- migrate:down
DROP TABLE IF EXISTS orders;

Data migration

-- 20250114121500_seed_categories.sql

-- migrate:up
INSERT INTO categories (name, slug) VALUES
('Electronics', 'electronics'),
('Clothing', 'clothing'),
('Books', 'books'),
('Home & Garden', 'home-garden'),
('Sports', 'sports')
ON CONFLICT (slug) DO NOTHING;

-- migrate:down
DELETE FROM categories
WHERE slug IN ('electronics', 'clothing', 'books', 'home-garden', 'sports');

Rollback sections

Rollback SQL lives in the same migration file, under the -- migrate:down marker:

-- 20250114120000_add_products_table.sql

-- migrate:up
CREATE TABLE products (...);

-- migrate:down
DROP TABLE IF EXISTS products;

pgstack migration rollback executes the -- migrate:down section of the most recently applied migration.

Migration table

pgStack tracks applied migrations in:

SELECT * FROM pgstack.migrations ORDER BY applied_at;
idnameapplied_at
100000000000000_pgstack_init2025-01-14 12:00:00+00
220250114120000_add_products_table2025-01-14 12:05:00+00

Best practices

One change per migration. Keep each migration focused. It's easier to roll back a single table drop than to undo a multi-table migration.

Always test rollbacks. Write rollback files and test them in development. A migration without a rollback is a liability.

Never edit applied migrations. Once a migration has been applied to any environment, don't modify it. Create a new migration instead.

Transactions are automatic. Each migration runs inside a single transaction (BEGIN ... COMMIT) — if any statement fails, the whole migration rolls back and is not recorded. Do not add explicit BEGIN/COMMIT to migration files.