Skip to content

Database Migrations

PayloadCMS uses a migration system to track and apply database schema changes. Every structural change — new fields, renamed columns, added indexes — needs a migration so the database stays in sync across environments.

Create a migration when:

  • Adding a new field to an existing collection
  • Changing a field type
  • Adding or modifying indexes
  • Creating custom tables
  • Transforming existing data

Do not create migrations for PayloadCMS config-only changes that do not affect the database schema.

Each migration is a TypeScript file with two exported functions:

src/migrations/20260204_100000.ts
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres';
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
// Apply changes
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
// Rollback changes
}
FunctionPurpose
up()Apply the migration — runs when you deploy
down()Rollback the migration — runs if you need to undo

Always write down(). It is what makes rollbacks possible when something goes wrong in production.

  1. Generate from the CLI

    Terminal window
    ddev pnpm payload migrate:create migration_name

    This creates a timestamped file in src/migrations/ with the up and down stubs ready to fill in. Prefer this over creating files manually so the timestamp is always correct.

  2. Write the migration

    src/migrations/20260204_100000.ts
    import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres';
    export async function up({ db }: MigrateUpArgs): Promise<void> {
    db.execute(sql`
    ALTER TABLE "pages"
    ADD COLUMN "new_field" varchar;
    `);
    }
    export async function down({ db }: MigrateDownArgs): Promise<void> {
    db.execute(sql`
    ALTER TABLE "pages"
    DROP COLUMN "new_field";
    `);
    }

    The sql tag prevents SQL injection and provides syntax highlighting in editors. down() is the exact inverse of up() — what you add, you drop; what you rename forward, you rename back.

  3. Run the migration

    Terminal window
    ddev pnpm payload migrate

    In development, pending migrations also run automatically when you start ddev pnpm dev.

export async function up({ db }: MigrateUpArgs): Promise<void> {
db.execute(sql`
CREATE INDEX "pages_new_field_idx"
ON "pages" USING btree ("new_field");
`);
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
db.execute(sql`
DROP INDEX "pages_new_field_idx";
`);
}
export async function up({ db }: MigrateUpArgs): Promise<void> {
db.execute(sql`
ALTER TABLE "pages"
RENAME COLUMN "old_name" TO "new_name";
`);
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
db.execute(sql`
ALTER TABLE "pages"
RENAME COLUMN "new_name" TO "old_name";
`);
}
Terminal window
ddev pnpm payload migrate:status

This lists which migrations have run and which are pending — useful before deploying to confirm the database is in the expected state.

  1. Test locally first before running on production
  2. Always write down() for rollback capability
  3. Back up data before running on an existing database
  4. One change per migration — do not combine unrelated schema changes
  5. Use descriptive namesmigrate:create add_excerpt_to_posts beats migrate:create update1
  1. Check the error message in the console
  2. Verify SQL syntax
  3. Ensure no conflicting migrations exist
  4. Run migrate:status to see which migrations are pending

Always test migrations against a copy of the production database. Never skip down().

If migrations fail due to table dependencies, drop tables in reverse order of creation.