# Run pending migrations
clojure -M:migrate up
# Rollback last migration
clojure -M:migrate down
# Check status (REPL)
(require '[boundary.platform.shell.database.migrations :as mig])
(mig/print-status)
Boundary uses Migratus for SQL-based, versioned migrations. This guide covers the naming convention, safe patterns, and common recipes.
# Run pending migrations
clojure -M:migrate up
# Rollback last migration
clojure -M:migrate down
# Check status (REPL)
(require '[boundary.platform.shell.database.migrations :as mig])
(mig/print-status)
Migrations use timestamp-based IDs:
YYYYMMDDHHMMSS-description.up.sql YYYYMMDDHHMMSS-description.down.sql
Examples from the codebase:
20260301000000-performance-indexes.up.sql 20260324000000-tenant-memberships.up.sql 20260324001000-tenant-member-invites.up.sql
Rules:
Always create both .up.sql and .down.sql — every migration must be reversible
Use descriptive kebab-case names: add-user-email-index, create-orders-table
Increment the last 6 digits when creating multiple migrations on the same day (e.g. 000000, 001000, 002000)
| Location | Purpose |
|---|---|
| Application-level migrations (main schema) |
| Library-contributed migrations (auto-discovered) |
Libraries contribute migrations by publishing a boundary/migration-paths.edn manifest on the classpath:
;; libs/geo/resources/boundary/migration-paths.edn
{:paths ["boundary/geo/migrations/"]}
The migration system (boundary.platform.shell.database.migrations) discovers the manifests and merges their directories at startup.
Migratus uses --;; to separate SQL statements within a single migration file. Always use this between statements:
CREATE TABLE orders (...);
--;;
CREATE INDEX idx_orders_user_id ON orders (user_id);
--;;
CREATE INDEX idx_orders_status ON orders (status);
-- up.sql
CREATE TABLE IF NOT EXISTS products (...);
--;;
CREATE INDEX IF NOT EXISTS idx_products_name ON products (name);
-- down.sql
DROP INDEX IF EXISTS idx_products_name;
--;;
DROP TABLE IF EXISTS products;
This makes migrations idempotent — safe to re-run if a partial failure occurred.
Never add a NOT NULL column without a default in one step — it will fail on existing rows.
-- Step 1: add nullable column
ALTER TABLE users ADD COLUMN phone VARCHAR(50);
-- Step 2 (separate migration, after backfill): add constraint
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;
On PostgreSQL, CREATE INDEX locks the table. Use CONCURRENTLY for large tables:
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_email ON orders (email);
CONCURRENTLY cannot run inside a transaction. Migratus runs each statement separately when split by --;;, so this works.
|
Create the referenced table before the referencing table. If they span separate migrations, use two migration files with ordered timestamps.
Renaming in one step breaks running application instances. Use a three-step approach:
Add new column, copy data
Deploy app that reads from new column
Drop old column
-- Migration 1: add new column
ALTER TABLE users ADD COLUMN full_name VARCHAR(255);
--;;
UPDATE users SET full_name = name;
-- Migration 2 (after app deploy): drop old column
ALTER TABLE users DROP COLUMN name;
For production deployments with no downtime:
| Operation | Safe approach |
|---|---|
Add column | Add as nullable (no default needed). App ignores it until next deploy. |
Remove column | Deploy app that stops reading it first. Then drop in next migration. |
Add index | Use |
Add table | Always safe — nothing references it yet. |
Drop table | Deploy app that stops using it first. Then drop. |
Change column type | Add new column, backfill, deploy, drop old column (3 steps). |
The key principle: schema changes and code changes deploy separately. The database schema should always be compatible with both the old and new application version.
When using boundary-tenant (schema-per-tenant), migrations run in two scopes:
Shared schema (public) — tenant registry, shared tables. Use resources/migrations/ as normal.
Per-tenant schema — tenant-specific tables. The tenant library applies these automatically during provisioning.
For per-tenant migrations, prefix table names with the schema:
-- Runs in the tenant's schema context
CREATE TABLE IF NOT EXISTS orders (
id VARCHAR(255) PRIMARY KEY,
tenant_id VARCHAR(255) NOT NULL,
...
);
Always include tenant_id in indexes for tenant-scoped queries:
CREATE INDEX IF NOT EXISTS idx_orders_tenant_status
ON orders (tenant_id, status);
For migrations that update many rows, process in batches to avoid long locks:
-- PostgreSQL: batch update with CTE
WITH batch AS (
SELECT id FROM users
WHERE status IS NULL
LIMIT 10000
)
UPDATE users SET status = 'active'
WHERE id IN (SELECT id FROM batch);
For very large tables, write a Clojure migration script instead of SQL and run it as a one-off task outside the migration framework.
CI runs all migrations against H2 - the test suite creates a fresh schema every run
Test locally against PostgreSQL before deploying to production (H2 doesn’t support all PostgreSQL features like CONCURRENTLY)
Rollback test: run migrate up, then migrate down, then migrate up again. If it fails, your down migration is broken.
# Full roundtrip test
clojure -M:migrate up
clojure -M:migrate down
clojure -M:migrate up
-- up.sql
CREATE TABLE IF NOT EXISTS products (
id VARCHAR(255) NOT NULL PRIMARY KEY,
tenant_id VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
price DECIMAL(10,2) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'draft',
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE
);
--;;
CREATE INDEX IF NOT EXISTS idx_products_tenant_id ON products (tenant_id);
--;;
CREATE INDEX IF NOT EXISTS idx_products_status ON products (status);
-- down.sql
DROP TABLE IF EXISTS products;
-- up.sql
ALTER TABLE orders ADD COLUMN priority INTEGER DEFAULT 0;
-- down.sql
ALTER TABLE orders DROP COLUMN IF EXISTS priority;
-- up.sql
ALTER TABLE users ADD CONSTRAINT uq_users_email UNIQUE (email);
-- down.sql
ALTER TABLE users DROP CONSTRAINT IF EXISTS uq_users_email;
-- up.sql
ALTER TABLE orders
ADD CONSTRAINT fk_orders_user_id
FOREIGN KEY (user_id) REFERENCES users (id);
-- down.sql
ALTER TABLE orders DROP CONSTRAINT IF EXISTS fk_orders_user_id;
Both .up.sql and .down.sql exist
Uses IF EXISTS / IF NOT EXISTS where applicable
Statements separated by --;;
No NOT NULL added to existing column without a default or backfill
Indexes use CONCURRENTLY for large tables (PostgreSQL)
Column names use snake_case (database boundary convention)
Tested roundtrip: up → down → up
Timestamp ID doesn’t collide with existing migrations
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |