The @supabase/pg-delta package provides a programmatic API for generating and applying migration plans.
npm install @supabase/pg-deltaimport { createPlan, applyPlan, renderPlanSql } from "@supabase/pg-delta";
import { supabase } from "@supabase/pg-delta/integrations/supabase";
// Create a migration plan
const result = await createPlan(
"postgresql://localhost:5432/source_db",
"postgresql://localhost:5432/target_db",
{ filter: supabase.filter, serialize: supabase.serialize }
);
if (result) {
const { plan } = result;
console.log(renderPlanSql(plan)); // executable SQL script (transaction-aware)
// Apply the plan
const applyResult = await applyPlan(
plan,
"postgresql://localhost:5432/source_db",
"postgresql://localhost:5432/target_db"
);
console.log(applyResult.status);
}import {
createPlan,
applyPlan,
renderPlanSql,
renderPlanFiles,
flattenPlanStatements,
UnorderableCycleError,
type Plan,
type MigrationUnit,
type CreatePlanOptions,
type IntegrationDSL,
} from "@supabase/pg-delta";import { supabase } from "@supabase/pg-delta/integrations/supabase";Create a migration plan by comparing two databases.
source(string | Sql): Source database connection URL or postgres.js client (current state)target(string | Sql): Target database connection URL or postgres.js client (desired state)options(CreatePlanOptions, optional): Configuration options
Promise<{ plan: Plan; sortedChanges: Change[]; ctx: DiffContext } | null>
- Returns an object with the plan and metadata if there are changes
- Returns
nullif databases are identical - Throws
UnorderableCycleErrorwhen the changes contain a dependency cycle that cannot be ordered;error.cyclecarries the offending changes
import { createPlan } from "@supabase/pg-delta";
const result = await createPlan(
process.env.SOURCE_DB_URL!,
process.env.TARGET_DB_URL!
);
if (result) {
console.log(`Found ${result.plan.units.length} migration units`);
console.log(renderPlanSql(result.plan));
} else {
console.log("No differences found");
}Apply a plan's migration units to a database with integrity checks. Validates fingerprints before and after application to ensure plan integrity.
Units are applied in order on a single session: transactional units inside an explicit BEGIN/COMMIT, non-transactional units without a wrapper. Multi-unit plans are therefore not atomic as a whole — a failure in a later unit does not roll back units that already committed.
plan(Plan): The migration plan to applysource(string | Sql): Source database connection URL or postgres.js clienttarget(string | Sql): Target database connection URL or postgres.js clientoptions(ApplyPlanOptions, optional): Configuration optionsverifyPostApply(boolean, default: true): Verify fingerprint after applying
Promise<ApplyPlanResult>
The result is a discriminated union with the following possible statuses:
type ApplyPlanResult =
| { status: "invalid_plan"; message: string }
| { status: "fingerprint_mismatch"; current: string; expected: string }
| { status: "already_applied" }
| { status: "applied"; statements: number; units: number; warnings?: string[] }
| {
status: "failed";
error: unknown;
script: string;
/** 0-based index of the unit that failed; undefined if a session statement failed. */
failedUnitIndex?: number;
/** Number of units fully committed before the failure. */
completedUnits: number;
};import { createPlan, applyPlan } from "@supabase/pg-delta";
const result = await createPlan(sourceUrl, targetUrl);
if (result) {
const applyResult = await applyPlan(result.plan, sourceUrl, targetUrl);
switch (applyResult.status) {
case "applied":
console.log(
`Applied ${applyResult.statements} statements across ${applyResult.units} units`,
);
break;
case "already_applied":
console.log("Plan already applied");
break;
case "fingerprint_mismatch":
console.error("Source database has changed since plan was created");
break;
case "failed":
console.error("Failed to apply:", applyResult.error);
break;
}
}A migration plan containing all changes to transform one database schema into another, as an ordered list of execution-aware migration units.
interface Plan {
version: number; // 2
toolVersion?: string;
source: { fingerprint: string };
target: { fingerprint: string };
units: MigrationUnit[];
sessionStatements: string[]; // SET ROLE, ... applied once per session
role?: string;
filter?: FilterDSL;
serialize?: SerializeDSL;
risk?: { level: "safe" } | { level: "data_loss"; statements: string[] };
}
interface MigrationUnit {
transactionMode: "transactional" | "none";
reason: "default" | "enum_value_visibility" | "non_transactional";
statements: string[];
}A plan needs more than one unit when a statement's effects only become usable after COMMIT (e.g. ALTER TYPE ... ADD VALUE, whose new value cannot be referenced in the same transaction — PostgreSQL error 55P04), or when a statement cannot run inside a transaction block at all (e.g. DROP SUBSCRIPTION with an associated replication slot).
Render a plan with renderPlanSql(plan) (single script) or renderPlanFiles(plan) (one numbered file per unit, as written by pgdelta plan --output-dir). flattenPlanStatements(plan) returns the raw ordered statements (session statements included) when transaction context does not matter.
Execution contract for rendered SQL: run scripts with a statement-splitting runner such as psql -f (each statement is sent separately, so session SETs apply and non-transactional units execute outside any transaction). Do not execute a rendered script as a single multi-statement query string (node-postgres query(file), PQexec) and do not use psql --single-transaction: PostgreSQL runs every statement of a multi-command string in an implicit transaction block, so any transactionMode: "none" unit would fail with 25001 regardless of statement ordering. For programmatic application use applyPlan, which executes statement-by-statement on a single session.
Legacy v1 plan JSON (flat statements array) is still accepted by deserializePlan/applyPlan and is normalized into a single transactional unit.
Options for creating a plan.
interface CreatePlanOptions {
/** Filter - either FilterDSL (stored in plan) or ChangeFilter function (not stored) */
filter?: FilterDSL | ChangeFilter;
/** Serialize - either SerializeDSL (stored in plan) or ChangeSerializer function (not stored) */
serialize?: SerializeDSL | ChangeSerializer;
/** Role to use when executing the migration (SET ROLE will be added to statements) */
role?: string;
}A serializable representation of an integration combining filter and serialization options.
type IntegrationDSL = {
/** Filter DSL - determines which changes to include/exclude */
filter?: FilterDSL;
/** Serialization DSL - customizes how changes are serialized */
serialize?: SerializeDSL;
};See Integrations for the full DSL documentation.
Integrations provide pre-configured filter and serialize options. Use them by spreading their properties into CreatePlanOptions:
import { createPlan } from "@supabase/pg-delta";
import { supabase } from "@supabase/pg-delta/integrations/supabase";
// Use the supabase integration
const result = await createPlan(sourceUrl, targetUrl, {
filter: supabase.filter,
serialize: supabase.serialize,
});You can create your own integration using the IntegrationDSL type:
import { createPlan, type IntegrationDSL } from "@supabase/pg-delta";
const myIntegration: IntegrationDSL = {
filter: {
schema: "public",
},
serialize: [
{
when: { type: "schema", operation: "create" },
options: { skipAuthorization: true },
},
],
};
const result = await createPlan(sourceUrl, targetUrl, {
filter: myIntegration.filter,
serialize: myIntegration.serialize,
});Both createPlan and applyPlan may throw errors for connection failures or database query errors. Always wrap calls in try-catch blocks:
try {
const result = await createPlan(sourceUrl, targetUrl);
// Handle result
} catch (error) {
console.error("Failed to create plan:", error);
process.exit(1);
}import { createPlan, applyPlan } from "@supabase/pg-delta";
async function migrate() {
const sourceUrl = process.env.SOURCE_DB_URL!;
const targetUrl = process.env.TARGET_DB_URL!;
const result = await createPlan(sourceUrl, targetUrl);
if (!result) {
console.log("No differences found");
return;
}
console.log("Migration plan:");
console.log(result.plan.statements.join(";\n"));
const applyResult = await applyPlan(result.plan, sourceUrl, targetUrl);
if (applyResult.status === "applied") {
console.log(`Successfully applied ${applyResult.statements} statements`);
}
}import { createPlan, applyPlan, renderPlanSql } from "@supabase/pg-delta";
import { supabase } from "@supabase/pg-delta/integrations/supabase";
const result = await createPlan(sourceUrl, targetUrl, {
filter: supabase.filter,
serialize: supabase.serialize,
});
if (result) {
await applyPlan(result.plan, sourceUrl, targetUrl);
}import { createPlan } from "@supabase/pg-delta";
// SET ROLE will be added to the beginning of the migration
const result = await createPlan(sourceUrl, targetUrl, {
role: "postgres",
});import { createPlan } from "@supabase/pg-delta";
// Only include changes in the public schema
const result = await createPlan(sourceUrl, targetUrl, {
filter: { schema: "public" },
});