A lightweight PostgreSQL schema migration tool. It runs timestamped SQL files in order, records what was applied, and fails when migration history is inconsistent.
- Node.js
>=22 - PostgreSQL
- ESM projects only
npm install --save @gabbe/pg-migrateThe package installs the pg-migrate CLI binary and exports a typed Node API.
- Quick Start
- Migration Files
- Configuration
- Commands
- History Rules
- Locking and Transactions
- Development
- License
Create a migration file:
pg-migrate create --name create_usersEdit the generated file:
-- migrate:up
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email text NOT NULL UNIQUE
);
-- migrate:down
DROP TABLE users;Apply pending migrations:
pg-migrate up --url postgres://localhost:5432/appCheck migration state:
pg-migrate status --url postgres://localhost:5432/appMigration files must be UTF-8 .sql files in this format:
<YYYYMMDDHHMMSS>_<slug>.sql
Example:
20260414153000_create_users.sql
Rules:
- The timestamp/version is 14 digits.
- The slug must match
[a-z0-9][a-z0-9_]*. - Files are applied in ascending version order.
- Non-
.sqlfiles are ignored. - Invalid
.sqlfilenames fail validation. - Duplicate versions fail validation.
- Commands that load migrations require the migrations directory to exist.
- Commands that load migrations fail if the directory contains no migration
.sqlfiles.
Each migration file must contain exactly one -- migrate:up marker and at most one -- migrate:down marker:
-- migrate:up
ALTER TABLE users ADD COLUMN name text;
-- migrate:down
ALTER TABLE users DROP COLUMN name;Rules:
- The
upsection is required and must contain SQL. - The
downmarker is optional. - Empty
downSQL is allowed. - During rollback, an empty or missing
downsection executes no SQL but still removes the migration from history. - If that migration is applied again later, its
upSQL runs again. Irreversible migrations should have idempotentupSQL or should not be rolled back. - Content before the first marker may only be comments or whitespace.
Database commands accept a database URL in one of these ways:
pg-migrate up postgres://localhost:5432/app
pg-migrate up --url postgres://localhost:5432/app
PGM_DATABASE_URL=postgres://localhost:5432/app pg-migrate upOr put it in a .env file in the current working directory:
PGM_DATABASE_URL=postgres://localhost:5432/app
PGM_MIGRATIONS_DIRECTORY=migrationsThen run:
pg-migrate upPrecedence:
- Use either positional
<database-url>or--url, not both. - Explicit CLI values win over environment variables.
- Environment variables win over
.envvalues. - Use
--env-file <path>to load a different env file.
| Variable | Used by | Default |
|---|---|---|
PGM_DATABASE_URL |
up, down, status, validate |
none |
PGM_MIGRATIONS_DIRECTORY |
all commands | migrations |
PGM_ENV_FILE |
all commands | .env |
.env files support simple KEY=value lines only. Line continuations, variable interpolation, and multi-line values are not supported. Set PGM_ENV_FILE to a custom path or an empty value to disable automatic .env loading.
The migrations table defaults to schema_migrations.
Use --table <table-name> for CLI database commands, or table in the Node API options.
Valid table names:
schema_migrationsschema_name.schema_migrations
Table names must be lowercase PostgreSQL-style identifiers. The schema must already exist when a schema-qualified name is used.
A schema-qualified migrations table only changes where migration history is stored. pg-migrate does not set search_path for migration SQL.
If migrations should affect a non-public schema, either qualify object names in the SQL:
-- migrate:up
CREATE TABLE app.users (
id SERIAL PRIMARY KEY
);If you set search_path inside a migration, reset it before the migration ends or use a schema-qualified migrations table such as --table public.schema_migrations. History writes happen in the same transaction as the migration SQL.
-- migrate:up
SET LOCAL search_path TO app;
CREATE TABLE users (
id SERIAL PRIMARY KEY
);
RESET search_path;pg-migrate <command> [options]
pg-migrate <command> --helpProgrammatic examples use the exported Node API. API calls accept a PostgreSQL connection string or a pg.ClientConfig object.
By default, API functions emit newline-delimited structured JSON logs to stderr. Pass logSink to route structured log records elsewhere.
correlationId adds the same id to every log record for one run, so application logs can be tied back to a specific migration call.
CLI commands exit 0 on success and 1 on expected failures. In human-readable mode, errors are logged to stderr. With --json, failures write { "command": <command|null>, "error": "<message>", "ok": false } to stdout; stderr still contains logs, rendered as JSON log records.
Creates a timestamped migration file.
pg-migrate create --name <name> [options]| Flag | Required | Description |
|---|---|---|
--name <name>, -n <name> |
yes | Migration slug. Must match [a-z0-9][a-z0-9_]*. |
--directory <dir>, -d <dir> |
no | Output directory. Defaults to PGM_MIGRATIONS_DIRECTORY (env or .env) or migrations. |
--json |
no | Emit a structured command result to stdout and JSON logs to stderr. |
--quiet |
no | Suppress non-error logs. |
--verbose, -v |
no | Enable debug logs. |
--env-file <path> |
no | Load environment variables from a custom env file. |
--no-color |
no | Disable ANSI color in human-readable logs. |
--help, -h |
no | Show command help. |
- Creates
<YYYYMMDDHHMMSS>_<name>.sql. - The timestamp is generated from the current UTC time.
- The output directory is created if it does not exist.
- The file is created with
-- migrate:upand-- migrate:downmarkers. - Existing files are not overwritten.
- Human-readable mode writes the created file path to
stdout. --jsonwrites{ "command": "create", "file": "<path>" }tostdout.- Logs are written to
stderr.
pg-migrate create --name create_users
pg-migrate create --directory sql/migrations --name add_user_index
pg-migrate create -d sql/migrations -n add_deleted_atcreate is only exposed through the CLI. The package does not currently export a public function for creating migration files.
Applies pending migrations.
pg-migrate up [options] [<database-url>]| Flag | Required | Description |
|---|---|---|
--url <database-url> |
no | Database URL. Alternative to positional <database-url>. |
--directory <dir>, -d <dir> |
no | Migrations directory. Defaults to PGM_MIGRATIONS_DIRECTORY (env or .env) or migrations. |
--target <target>, -t <target> |
no | Apply pending migrations up to and including this target. |
--table <table-name> |
no | Migrations table. Defaults to schema_migrations. |
--dry-run |
no | Run planned SQL and history writes, then roll back. |
--json |
no | Emit a structured command result to stdout and JSON logs to stderr. |
--quiet |
no | Suppress non-error logs. |
--verbose, -v |
no | Enable debug logs. |
--env-file <path> |
no | Load environment variables from a custom env file. |
--no-color |
no | Disable ANSI color in human-readable logs. |
--help, -h |
no | Show command help. |
--target accepts either:
<YYYYMMDDHHMMSS>
<YYYYMMDDHHMMSS>_<slug>.sql
- Without
--target, applies all pending migrations. - With
--target, stops after the target migration has been applied. - Creates the migrations table if it does not exist.
- Validates files and applied history before running migration SQL.
- Fails if applied history has gaps, duplicates, or versions missing on disk.
- Fails if the target is behind the latest applied migration.
- Runs each migration file in its own transaction.
- Stops at the first failed migration.
- Uses a PostgreSQL advisory lock for the full run.
- Human-readable mode writes no command result on success.
--jsonwrites{ "command": "up", "dryRun": <boolean>, "ok": true, "target": <target|null> }tostdout.- Logs are written to
stderr.
pg-migrate up postgres://localhost:5432/app
pg-migrate up --url postgres://localhost:5432/app
pg-migrate up --url postgres://localhost:5432/app --target 20260416090000
pg-migrate up --url postgres://localhost:5432/app --target 20260416090000_create_users.sql
pg-migrate up --dry-runimport { up } from "@gabbe/pg-migrate";
await up("postgres://localhost:5432/app", {
directory: "migrations",
table: "schema_migrations",
});With --target behavior:
await up("postgres://localhost:5432/app", {
directory: "migrations",
table: "schema_migrations",
target: "20260416090000_create_users.sql",
});With --dry-run behavior:
await up("postgres://localhost:5432/app", {
directory: "migrations",
dryRun: true,
table: "schema_migrations",
});Programmatic options: directory, table, target, dryRun, quiet, verbose, logSink, and correlationId.
Rolls back the latest applied migration, or multiple newer migrations when --target is given.
pg-migrate down [options] [<database-url>]| Flag | Required | Description |
|---|---|---|
--url <database-url> |
no | Database URL. Alternative to positional <database-url>. |
--directory <dir>, -d <dir> |
no | Migrations directory. Defaults to PGM_MIGRATIONS_DIRECTORY (env or .env) or migrations. |
--target <target>, -t <target> |
no | Roll back newer migrations while leaving this target applied. |
--table <table-name> |
no | Migrations table. Defaults to schema_migrations. |
--dry-run |
no | Run planned SQL and history writes, then roll back. |
--json |
no | Emit a structured command result to stdout and JSON logs to stderr. |
--quiet |
no | Suppress non-error logs. |
--verbose, -v |
no | Enable debug logs. |
--env-file <path> |
no | Load environment variables from a custom env file. |
--no-color |
no | Disable ANSI color in human-readable logs. |
--help, -h |
no | Show command help. |
--target accepts either:
<YYYYMMDDHHMMSS>
<YYYYMMDDHHMMSS>_<slug>.sql
- Without
--target, rolls back exactly one migration: the latest applied migration. - With
--target, rolls back newer migrations and leaves the target migration applied. - The target migration must already be applied.
- Creates the migrations table if it does not exist.
- Validates files and applied history before running rollback SQL.
- Runs each rollback in its own transaction when
downSQL exists. - If
downSQL is empty or missing, no SQL is run and the migration is still removed from history. - Uses a PostgreSQL advisory lock for the full run.
- Human-readable mode writes no command result on success.
--jsonwrites{ "command": "down", "dryRun": <boolean>, "ok": true, "target": <target|null> }tostdout.- Logs are written to
stderr.
pg-migrate down postgres://localhost:5432/app
pg-migrate down --url postgres://localhost:5432/app
pg-migrate down --url postgres://localhost:5432/app --target 20260416090000
pg-migrate down --url postgres://localhost:5432/app --target 20260416090000_create_users.sql
pg-migrate down --dry-runimport { down } from "@gabbe/pg-migrate";
await down("postgres://localhost:5432/app", {
directory: "migrations",
table: "schema_migrations",
});With --target behavior:
await down("postgres://localhost:5432/app", {
directory: "migrations",
table: "schema_migrations",
target: "20260416090000_create_users.sql",
});With --dry-run behavior:
await down("postgres://localhost:5432/app", {
directory: "migrations",
dryRun: true,
table: "schema_migrations",
});Programmatic options: directory, table, target, dryRun, quiet, verbose, logSink, and correlationId.
Shows applied and pending migration state.
pg-migrate status [options] [<database-url>]| Flag | Required | Description |
|---|---|---|
--url <database-url> |
no | Database URL. Alternative to positional <database-url>. |
--directory <dir>, -d <dir> |
no | Migrations directory. Defaults to PGM_MIGRATIONS_DIRECTORY (env or .env) or migrations. |
--table <table-name> |
no | Migrations table. Defaults to schema_migrations. |
--json |
no | Emit a structured command result to stdout and JSON logs to stderr. |
--quiet |
no | Suppress non-error logs. |
--verbose, -v |
no | Enable debug logs. |
--env-file <path> |
no | Load environment variables from a custom env file. |
--no-color |
no | Disable ANSI color in human-readable logs. |
--help, -h |
no | Show command help. |
- Validates migration files and applied history consistency.
- Shows the migrations table, migrations directory, initialization state, current migration, next migration, applied count, pending count, total count, and per-file state.
currentis the latest applied migration by file order.nextis the first pending migration by file order.- Does not create a missing migrations table.
- Reports
initialized: falsewhen the migrations table does not exist. - Uses a PostgreSQL advisory lock for the full run.
- Human-readable mode writes a status report to
stdout. --jsonwrites{ "command": "status", "ok": true, ...status }tostdout.- Logs are written to
stderr.
Table: schema_migrations
Directory: migrations
Initialized: true
Current: 20260414153000_create_users.sql
Next: (none)
Applied: 1
Pending: 0
Total: 1
pg-migrate status postgres://localhost:5432/app
pg-migrate status --url postgres://localhost:5432/app
pg-migrate status --url postgres://localhost:5432/app --table schema_migrations
pg-migrate status --jsonimport { status } from "@gabbe/pg-migrate";
const result = await status("postgres://localhost:5432/app", {
directory: "migrations",
table: "schema_migrations",
});result contains current, next, initialized, summary, and the per-file migrations list.
Programmatic options: directory, table, quiet, verbose, logSink, and correlationId.
Checks migration files, database connectivity, and migration history without applying SQL.
pg-migrate validate [options] [<database-url>]| Flag | Required | Description |
|---|---|---|
--url <database-url> |
no | Database URL. Alternative to positional <database-url>. |
--directory <dir>, -d <dir> |
no | Migrations directory. Defaults to PGM_MIGRATIONS_DIRECTORY (env or .env) or migrations. |
--table <table-name> |
no | Migrations table. Defaults to schema_migrations. |
--json |
no | Emit a structured command result to stdout and JSON logs to stderr. |
--quiet |
no | Suppress non-error logs. |
--verbose, -v |
no | Enable debug logs. |
--env-file <path> |
no | Load environment variables from a custom env file. |
--no-color |
no | Disable ANSI color in human-readable logs. |
--help, -h |
no | Show command help. |
- Validates migration files, ordering, SQL markers, and applied history consistency.
- Checks database connectivity.
- Checks the migrations table shape.
- Does not create a missing migrations table.
- Fails if the migrations table does not exist.
- Uses a PostgreSQL advisory lock for the full run.
- Human-readable mode writes no command result on success.
--jsonwrites{ "command": "validate", "ok": true }tostdout.- Logs are written to
stderr.
pg-migrate validate postgres://localhost:5432/app
pg-migrate validate --url postgres://localhost:5432/app
pg-migrate validate --url postgres://localhost:5432/app --table schema_migrations
pg-migrate validate --jsonimport { validate } from "@gabbe/pg-migrate";
await validate("postgres://localhost:5432/app", {
directory: "migrations",
table: "schema_migrations",
});Programmatic options: directory, table, quiet, verbose, logSink, and correlationId.
Before running migration SQL, pg-migrate validates that applied migrations still match disk migrations.
It fails when:
- An applied migration version is missing on disk.
- Applied history contains duplicate versions.
- Applied migrations do not form a contiguous prefix of the ordered migration files.
- A target migration cannot be found.
- An
up --targetmigration is older than the latest applied migration.
The migration slug is not part of applied identity. Renaming 20260414153000_create_users.sql to 20260414153000_create_accounts.sql keeps the migration applied because the 20260414153000 timestamp is unchanged.
up is append-only by version order. If you add an older migration after a newer migration has already been applied, up fails instead of applying it out of order.
Migration database commands use a PostgreSQL advisory lock for the full run. The lock key is based on the unqualified migrations table name.
Examples:
schema_migrationspublic.schema_migrations
Both use the same lock key, so they serialize with each other. Use different table names if that matters.
Warning: separate services that share one PostgreSQL database and use the same unqualified migrations table name will block each other, even when their migrations tables are in different schemas.
Transaction behavior:
upruns each migration file in its own transaction.downruns each rollback in its own transaction whendownSQL exists.--dry-runwraps the planned run in a transaction and always rolls it back.- Earlier successful migrations stay committed when a later migration fails.
- If one statement inside an
upmigration file fails, that file's transaction is rolled back and no history row is inserted. - If one statement inside a
downmigration file fails, that file's transaction is rolled back and the existing history row remains. pg-migratedoes not store failed or dirty migration rows.- PostgreSQL commands that cannot run inside a transaction block, such as
CREATE INDEX CONCURRENTLY, are not supported in normal migration files.
git clone https://github.com/gabts/pg-migrate
cd pg-migrate
npm install
npm run build:watchRun tests with a PostgreSQL database:
PGM_DATABASE_URL="postgres://localhost:5432/database" npm run test