Migrations
The migrations endpoint accepts a set of migrations, lints them, and optionally
applies them to an environment. The lint step is always performed first —
apply: false is the safe default for reviewing a migration plan before
committing it.
Endpoint
POST /v1/organizations/{org}/projects/{project}/environments/{env}/migrations
Request body:
{
"migrations": [
{
"version": "1",
"name": "init",
"up": "CREATE TABLE orders (id uuid primary key);",
"down": "DROP TABLE orders;"
},
{
"version": "2",
"name": "add_user_id",
"up": "ALTER TABLE orders ADD COLUMN user_id uuid NOT NULL;"
}
],
"apply": false
}Set apply: true to execute the migrations after lint passes.
Response 200 OK (lint only):
{
"plan": [
{
"version": "1",
"name": "init",
"direction": "up",
"findings": []
},
{
"version": "2",
"name": "add_user_id",
"direction": "up",
"findings": [
{
"severity": "warning",
"message": "Adding a NOT NULL column without a default to a large table may cause a full rewrite."
}
]
}
],
"applied": false
}Response 200 OK (applied):
{
"plan": [...],
"applied": true,
"applied_at": "2026-01-01T12:00:00Z"
}Lint findings
| Severity | Meaning |
|---|---|
error |
Migration will fail or cause data loss. Blocked — apply cannot proceed. |
warning |
Migration may cause problems at scale or violate best practices. |
info |
Informational note. |
Lock-safety rules
Four rules catch a migration that would lock a live table — or fail
outright — before it ever reaches Postgres. Neither supabase db push nor
Neon's branch/merge flow checks for any of this, because it's Postgres's own
lock and transaction behavior, not a feature either vendor has opted into.
| Rule | Severity | Blocks on | Why |
|---|---|---|---|
unsafe_column_rewrite |
Block | ALTER COLUMN ... TYPE ... on a table that already existed before this migration set |
Changing a column's type takes Postgres's ACCESS EXCLUSIVE lock for a full-table rewrite — every read and write on the table stops for as long as the rewrite takes, which on a populated table is real, visible downtime from a routine-looking migration. |
unsafe_not_null_scan |
Warn | ALTER COLUMN ... SET NOT NULL on a table that already existed before this migration set |
Proving every existing row already satisfies NOT NULL takes that same ACCESS EXCLUSIVE lock for the scan — read-only, but the lock it runs under is not. This is a warning, not a block, because whether it actually matters depends on the table's size, which the lint can't know from the SQL alone. |
unsafe_check_constraint_scan |
Warn | ALTER TABLE ... ADD [CONSTRAINT name] CHECK (...) with no trailing NOT VALID, on a table that already existed before this migration set |
Same ACCESS EXCLUSIVE-held scan as unsafe_not_null_scan, just proving a CHECK expression instead of a NOT NULL — and for the same reason a warning, not a block: it's a scan, not a rewrite, so the real risk depends on table size. This is also what makes unsafe_not_null_scan's own recommended fix (ADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID) unsafe again if the NOT VALID is dropped — a one-word omission this rule exists specifically to catch. |
not_transactional |
Block | CREATE/DROP/REINDEX ... CONCURRENTLY, VACUUM, CREATE/DROP DATABASE, CREATE/DROP TABLESPACE, ALTER SYSTEM, anywhere in the migration |
Every migration here applies inside one transaction, together with its ledger row. Postgres refuses these specific statements the instant a transaction block is already open (SQLSTATE 25001) — not slowly, not with a warning, refused — so without this rule the migration would reach Postgres and fail there instead of being caught by the lint. This is also the statement the other two scan/rewrite rules' own fix recommends (build the index CONCURRENTLY instead of taking the full-table lock), so this rule exists specifically to stop that fix from being suggested without a safe way to apply it. |
A table created earlier in the same migration set is exempt from the first
three rules — nothing has read or written it yet, so there's no live traffic
for the lock to block. not_transactional has no such exemption: the
refusal is Postgres's transaction machinery, not a size- or traffic-dependent
risk, so it applies the same way regardless of whether the table is brand
new. The fix for not_transactional is different from the other three as
well: run the one statement directly against the environment's connection
string, outside any migration (the console's SQL editor also runs every
script in one transaction, so it refuses the same statement with the same
reason — a direct psql session against the connection string is the one
place on Scribase that doesn't).
{
"plan": [
{
"version": "7",
"name": "widen_order_total",
"direction": "up",
"findings": [
{
"severity": "error",
"message": "changing the type of orders.total rewrites every existing row under an ACCESS EXCLUSIVE lock — the table is unreadable and unwritable for the whole rewrite, not just slow. Add a new column in the new type, backfill it in batches, cut over the application, then drop the old column in separate migrations instead."
}
]
}
],
"applied": false
}The fix is the same shape for unsafe_column_rewrite,
unsafe_not_null_scan, and unsafe_check_constraint_scan: split one
migration that locks a live table into several that don't. For a type
change, add the new-typed column alongside the old one, backfill it in
batches, cut the application over, then drop the old column — four small
migrations instead of one blocking one. For SET NOT NULL, add
CHECK (col IS NOT NULL) NOT VALID first (instant, no scan),
VALIDATE CONSTRAINT it in its own migration (scans, but under the much
weaker SHARE UPDATE EXCLUSIVE lock that still allows reads and writes),
then SET NOT NULL — which Postgres can prove instantly once the validated
check already guarantees it. Any other CHECK constraint added to an
existing table gets the same two-step treatment directly: add it
NOT VALID first, then VALIDATE CONSTRAINT it in its own migration —
leaving off NOT VALID on that very statement is exactly what
unsafe_check_constraint_scan flags, including when it's the NOT NULL
check from the paragraph above.
Reaching for that same fix — building the index itself CONCURRENTLY
instead of taking the full-table lock — is exactly what trips
not_transactional, and splitting it into more migrations doesn't help:
the problem isn't the migration's size, it's that CONCURRENTLY cannot run
inside any transaction, anywhere, and every migration here runs inside one.
Run it directly against the connection string instead:
psql "$SCRIBASE_CONNECTION_STRING" \
-c "CREATE INDEX CONCURRENTLY orders_total_idx ON orders (total);"Row-level security rule
| Rule | Severity | Blocks on | Why |
|---|---|---|---|
missing_rls |
Block | A public-schema table that doesn't both enable RLS and attach at least one policy, within the same migration set that creates it |
The single most common Supabase data leak is a table left readable and writable by anyone who reaches the Data API, because RLS was never turned on. Scribase refuses to apply the migration at all rather than ship the table first and rely on a follow-up migration (or a human remembering) to lock it down. |
Three ways a table can fail this rule, each with its own message so the fix is obvious:
- Neither RLS nor a policy — the table is wide open. Message:
has neither RLS enabled nor any policy. - RLS enabled, no policy — the table is locked to everyone, including its own owner, which usually means the policy was forgotten. Message:
enables RLS but defines no policy (so every row is denied). - A policy exists, but RLS was never turned on — the policy is dead code; without RLS enabled, every row is still public regardless of what the policy says. Message:
has a policy but never enables RLS (so the policy is inert).
{
"plan": [
{
"version": "3",
"name": "add_orders_table",
"direction": "up",
"findings": [
{
"severity": "error",
"message": "table public.orders has neither RLS enabled nor any policy. Add `ALTER TABLE public.orders ENABLE ROW LEVEL SECURITY;` and at least one `CREATE POLICY ... ON public.orders` before shipping."
}
]
}
],
"applied": false
}supabase db push and a raw psql migration both apply this exact table unchanged — Postgres has no opinion on whether RLS is on, so neither Supabase's CLI nor Neon's branch/merge flow checks for it. Scribase does, at migration time, before any statement runs.
Migration version ordering
Migrations are ordered by their version field parsed as a number. The server
applies them in ascending order and skips already-applied versions.
CLI shortcut
# Lint only
scribase migrate acme store production --dir ./migrations
# Apply
scribase migrate acme store production --dir ./migrations --applySee scribase migrate for directory and file formats.