Write a database migration
A migration is a class that changes the database schema or its data. Persistence explains the data layer around migrations.
After a release, you cannot edit a migration, i.e. only a new migration can fix a buggy one.
How n8n runs migrations
- Every process migrates at startup. Mains, workers, and webhook processes run pending migrations when they start. A process is not ready until its migrations finish.
- PostgreSQL migrates one instance at a time. Each instance waits for a lock before it runs the migrations.
- On PostgreSQL, all pending migrations share one transaction. If one migration fails, n8n undoes all of them, and the schema stays as it was before the upgrade.
- On SQLite, each migration has its own transaction. If one migration fails, the migrations before it stay applied.
- Old code ignores new migrations. After a downgrade, n8n does not undo migrations that it does not know. To undo a migration, run
db:revertbefore the downgrade. - Engine v2 has its own migrations. They live in
packages/and target a separate database. This page does not cover them.@n8n/ engine/ src/ database/ migrations/
Create a migration
n8n supports SQLite and PostgreSQL. Migrations live in three folders under packages/: common/ for both databases, postgresdb/, and sqlite/. Pick the folder by how much your change differs between the two databases:
- Same on both:
common/ - Small difference (e.g. one statement or slightly different syntax):
common/, with a branch onisPostgresorisSqlite - Large difference (e.g. different steps to rebuild a table): two migrations, one in
postgresdb/and one insqlite/ - PostgreSQL only (e.g. a column type change): one migration in
postgresdb/, and none for SQLite
From repository root, generate the migration:
pnpm --filter=@n8n/db migration:new AddTracingToExecution
The generator writes <timestamp>-AddTracingToExecution.ts to common/. For postgresdb/ or sqlite/, add --folder=postgresdb or --folder=sqlite.
Write up and down
Each method receives a MigrationContext, which holds the schema builder, runQuery for raw SQL, and helpers for escaping and batching.
Example from the agents feature, with the schema builder and raw SQL:
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class CreateAgentPublishHistory1793035273653 implements ReversibleMigration {
async up(ctx: MigrationContext) {
const { escape, runQuery } = ctx;
// Raw ALTER preserves incoming SQLite foreign keys.
await runQuery(
`ALTER TABLE ${escape.tableName('agents')} ADD COLUMN ${escape.columnName('publicationRevision')} integer NOT NULL DEFAULT 0`,
);
const { createTable, column } = ctx.schemaBuilder;
await createTable('agent_publish_history')
.withColumns(
column('id').int.primary.autoGenerate2,
column('agentId').varchar(36).notNull,
column('event')
.varchar(16)
.notNull.withEnumCheck(['activated', 'deactivated'])
.comment('Publication or explicit unpublication'),
// ...
)
.withCreatedAt.withIndexOn(['agentId', 'id'])
.withForeignKey('agentId', { tableName: 'agents', columnName: 'id', onDelete: 'CASCADE' });
// ...
}
async down({ schemaBuilder: { dropTable }, escape, runQuery }: MigrationContext) {
await dropTable('agent_publish_history');
await runQuery(
`ALTER TABLE ${escape.tableName('agents')} DROP COLUMN ${escape.columnName('publicationRevision')}`,
);
}
}
Use IrreversibleMigration only when up() deletes the data that down() needs.
Do not write transaction code in a migration. n8n already runs each migration in a transaction.
In a migration, import application code only as types, and copy any helper you need into the file. Imported code can change later, and the migration must keep working.
Move data safely
- Expect bad data. Old rows can hold
null, an old JSON shape, or JSON that does not parse. Check each value before you use it. - Work in SQL where you can. One
INSERT ... SELECTis faster than a loop in TypeScript. - Read in batches. Never run
SELECT *without a limit. UserunInBatcheswith 100 to 500 rows, orcopyTable. - Fix the order. Add an
ORDER BYto each backfill, so that a second run gives the same result. - Count before you swap. When you copy a table to replace the old one, compare the row counts first.
Drop columns in two releases
Do not drop a column in the same release that stops using it. During a rolling deploy, the previous release still runs while the new one starts. A rollback also starts the previous release again. Both run against the migrated schema, so the previous release fails if its column is gone.
Split the removal into two releases:
- First release: Remove all code that reads or writes the column, and keep the column.
- Later release: Drop the column in a migration. No running code uses it any more.
Update the entity
Make the entity in packages/, or in the module that owns the table, match the migration. Give each index that the migration creates an @Index on the entity too.
Then update the schema docs from repository root. The command needs Docker and tbls:
pnpm db:schema:docs
Test the migration
Write a test in packages/ for each data migration, and for most schema migrations. Insert test data with raw SQL, because a repository uses the current entity, not the schema of the migration. packages/ lists the helpers.
Example from the scheduler, where down() must keep the rows of a child table:
describe('down', () => {
it('drops the column without deleting a job or a queued task', async () => {
await runSingleMigration(MIGRATION_NAME);
const seeded = createTestMigrationContext(dataSource);
await insertJob(seeded, 'job');
await insertTaskFor(seeded, 'job');
await seeded.queryRunner.release();
await undoLastSingleMigration();
const context = createTestMigrationContext(dataSource);
expect(await columnNames(context, 'scheduled_job')).not.toContain('lastDispatchedAt');
expect(await countRows(context, 'scheduled_job')).toBe(1);
expect(await countRows(context, 'scheduled_task')).toBe(1);
await context.queryRunner.release();
});
});
Run the test on both databases from packages/. The PostgreSQL command needs Docker.
pnpm test:sqlite:migrations \
test/migration/1793603668718-add-last-dispatched-at-to-scheduled-job.test.ts
pnpm test:postgres:migrations:tc \
test/migration/1793603668718-add-last-dispatched-at-to-scheduled-job.test.ts
Then check it on a local instance: pnpm start applies the migration, and pnpm start -- db:revert reverts it.
Get a review
A PR with a migration needs an approval from @n8n-io/. Before you open the PR, go through the pre-flight checklist in .agents/. Each item is a fix that reviewers often ask for.