TypeORM Migration Workflows

TypeORM ships with a feature that is perfect for a weekend prototype and catastrophic in production: synchronize: true. When it is on, TypeORM diffs your entity classes against the live database every time the application boots and silently runs whatever DDL it thinks will reconcile them — including DROP COLUMN on a field you just renamed, taking the data with it. The entire discipline of running TypeORM safely begins with turning that off and replacing it with an explicit, reviewable migration history: timestamped TypeScript classes with an up() and a down(), generated by diffing entities against the schema, committed to version control, and applied as a discrete deploy step. This guide is the operational runbook for that workflow — for the backend engineers who author entities, the DevOps engineers who wire migration:run into a release, and the on-call who has to read the migrations ledger when a deploy half-applies at 3 a.m.

Everything here assumes the migration is the deploy step that runs before the new application image, the discipline the broader ORM & Framework Migration Workflows guide establishes for every framework, and the same additive, forward-only contract the Expand and Contract Methodology defines for evolving a schema under live traffic. Three failure surfaces are common enough to have their own runbooks: the boot-time auto-sync you must switch off first, covered in disabling TypeORM synchronize in production; the timestamp-ordering trap that strikes when two branches merge migrations, covered in resolving TypeORM out-of-order migration errors; and the destructive DDL a naive migration:generate emits, covered in generating safe TypeORM migrations from entities.

Concept & Mechanism

TypeORM keeps two representations of your schema. The desired state lives in your entity classes — the @Entity, @Column, and @Index decorators the application compiles against. The applied history lives in a table (named migrations by default, configurable via migrationsTableName) that records every migration class already run, keyed by its timestamp and name. A migration is a plain TypeScript class implementing MigrationInterface, with an up(queryRunner) that moves the schema forward and a down(queryRunner) that reverses it, both issuing raw SQL through queryRunner.query(...).

typeorm migration:generate is the diff engine. It connects to a target database, introspects the live catalog, compares it against the metadata TypeORM derives from your entities, and writes a new migration class whose up() contains the SQL to make the database match the entities — and whose down() contains TypeORM’s best guess at the inverse. That inference is good but not infallible, in exactly the way the Idempotent Script Design guide warns: a column rename almost always surfaces as a DROP COLUMN followed by an ADD COLUMN, which is data loss dressed up as a schema change. The generated SQL is a draft to review, never a command to trust.

typeorm migration:run is the delivery tool. It loads every compiled migration, sorts them in ascending timestamp order, filters out the ones already recorded in the migrations table, and executes the remainder in that order, inserting a ledger row after each up() succeeds. By default the whole batch runs inside a single transaction (transaction: "all"); you can switch to one transaction per migration (transaction: "each") or none (transaction: "none") when a statement cannot run transactionally. On PostgreSQL that transactional wrapper means a failed migration rolls back cleanly and leaves the ledger untouched. On MySQL 8.0 there is no transactional DDL — each ALTER TABLE implicitly commits — so a failed migration:run can leave a class half-applied and unrecorded, a divergence the Transactional vs Non-Transactional Databases guide treats in depth.

The ordering rule is the source of the framework’s sharpest surprise. TypeORM decides what to run by comparing class timestamps against the ledger, not by comparing against a linear chain. If a migration with an earlier timestamp is merged onto main after a later-timestamped migration has already been applied to production, the next migration:run will happily execute the older migration after the newer one already changed the schema — running history out of the order it was authored in. That is the mechanism behind the out-of-order migration errors that surface as a QueryFailedError when the late-arriving migration assumes objects that a newer migration already altered or removed.

The three artifacts TypeORM keeps in sync Entity classes hold the desired state; migration:generate diffs them against the live database and writes a migration class with up and down; migration:run applies that class and inserts a ledger row into the migrations table. A crossed-out arc shows synchronize:true reconciling entities straight to the database, bypassing the migration history. The Three Artifacts TypeORM Keeps in Sync Entity classes @Entity · @Column desired state Migration class up() / down() reviewable history Live database + migrations ledger actual state migration:generate diff → new class migration:run apply + ledger row generate also introspects the live catalog synchronize:true skips the migration history entirely — never in production
Migrations sit between entities and the database as the reviewable ledger of every change; synchronize:true is the shortcut that erases that ledger.

Prerequisites & Decision Criteria

Decide how your service will apply schema changes before you write the first entity, because retrofitting a migration history onto a database that synchronize has been mutating is far harder than starting clean.

The table below maps each situation to the exact command, so nobody has to guess at the terminal.

Situation Command Execution context What it touches
Author a change from edited entities typeorm migration:generate Local or CI against a scratch DB; read-only on prod Writes a new migration file only
Hand-write a data or non-transactional migration typeorm migration:create Local; no DB connection Writes an empty migration skeleton
Apply pending migrations typeorm migration:run Deploy step, migration role, before app rollout Runs up(), inserts ledger rows
Reverse the last applied migration typeorm migration:revert Migration role; only when down() is safe Runs one down(), deletes its ledger row
Inspect applied vs pending typeorm migration:show Any environment; read-only Reads the migrations ledger

The decision is mechanical: editing entities and needing SQL is migration:generate; writing a backfill or an index that cannot run in a transaction is migration:create; shipping committed migrations is migration:run; undoing exactly one migration whose down() you trust is migration:revert. If you ever find yourself tempted to flip synchronize: true to “just fix it” against a database you cannot afford to lose, stop — that is always the wrong tool, and disabling TypeORM synchronize in production explains why.

Step-by-Step Procedure

This is the path from a local entity edit to a verified production deploy. The DataSource file referenced throughout (data-source.ts) is the single source of connection and migration configuration.

1. Pin the DataSource to explicit migrations. Before anything else, confirm the configuration disables implicit sync and points at compiled migration files.

// data-source.ts · TypeScript · loaded by both the app and the CLI · no schema change happens here.
// synchronize:false and migrationsRun:false are the two switches that keep boot-time DDL from ever firing.
import { DataSource } from "typeorm";

export const AppDataSource = new DataSource({
  type: "postgres",               // or "mysql"
  url: process.env.DATABASE_URL,
  synchronize: false,             // NEVER true outside a throwaway local database
  migrationsRun: false,           // apply migrations as an explicit step, not at boot
  migrationsTableName: "migrations",
  entities: ["dist/entity/*.js"],
  migrations: ["dist/migration/*.js"],
});

Verify before proceeding: grep the built config for synchronize and assert it is false. A single true here defeats the entire workflow.

2. Generate the migration from edited entities. Edit the entity classes, build, then diff against a scratch database that mirrors production’s schema.

# bash · run locally or in CI against a THROWAWAY database · read-only against production.
# migration:generate introspects the DB and writes a class; it does NOT apply anything.
npm run build
npx typeorm migration:generate ./src/migration/AddUserStatus \
  -d ./dist/data-source.js

Verify before proceeding: open the generated src/migration/<timestamp>-AddUserStatus.ts and read every queryRunner.query(...) call. If the up() contains a DROP COLUMN that is really a rename, or the down() would drop a column a backfill fills, fix the entities or hand-edit the SQL before committing — the full technique is in generating safe TypeORM migrations from entities.

3. Split destructive change into expand and contract. If the diff wants to drop or retype a column, do not ship it as one migration. Emit only the additive half now.

-- PostgreSQL · migration role · additive and backward compatible · safe under live traffic.
-- CREATE INDEX CONCURRENTLY must run OUTSIDE a transaction — set transaction:"none" for this migration.
ALTER TABLE users ADD COLUMN IF NOT EXISTS status_flag VARCHAR(32);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_status ON users (status_flag);

Verify before proceeding: confirm the old code path still reads and writes the pre-existing columns. The destructive contract step ships only after the backfill and the new code are fully rolled out, per Backfill Optimization.

4. Apply the migration in production. Run migration:run as a discrete, gated step ahead of the application rollout, using the migration role.

# bash · production deploy step · migration DB role · runs BEFORE the new app image · already-applied migrations are skipped.
npx typeorm migration:run -d ./dist/data-source.js

Verify before proceeding: the command prints one Migration <name> has been executed successfully line per pending class and exits 0. Reserve a dedicated connection budget for this step so it does not contend with the live fleet for pooled connections.

5. Roll out the application image. Only after the schema is live do you promote the code that depends on it. Because the entities compiled against the new columns, and the database now has them, the two contracts agree.

# bash · deploy pipeline · no direct DB access · runs AFTER migration:run exits 0.
kubectl set image deployment/api api=registry.example.com/api:$GIT_SHA
Additive migration runs before the rolling image swap Along a left-to-right timeline, the migration role applies additive DDL and exits; the database transitions from the old schema to an expanded backward-compatible schema; the old app image serves traffic across the whole window while the new image starts partway through, so the old code runs against the expanded schema during the overlap. Migration First, Then the Rolling Swap Migration role Database Old app image New app image migration:run exits 0 migration:run — DDL old schema expanded schema — backward compatible old image serving traffic new image serving traffic applies additively overlap window — old code runs against the expanded schema
The schema is expanded and compatible before the first new pod starts, so old and new images can run side by side during the swap.

Verification & Observability

A migration is not done when migration:run exits 0; it is done when the ledger, the catalog, and the running fleet all agree, and no session is stuck on a lock. Start with TypeORM’s own view of applied versus pending:

# bash · any environment · read-only · a leading [X] marks applied, [ ] marks pending in the ledger.
npx typeorm migration:show -d ./dist/data-source.js

Confirm the same from the ledger table directly, which reveals the exact order migrations were recorded — the fastest way to spot an out-of-order apply:

-- PostgreSQL · read-only · safe any time against production.
SELECT id, "timestamp", name
FROM migrations
ORDER BY "timestamp" DESC
LIMIT 10;

If the largest timestamp is not the most recently authored migration, history ran out of order and the out-of-order runbook applies. Next, confirm the entities and the live schema actually agree by generating against production with a read-only role and asserting the diff is empty:

# bash · CI post-deploy gate · READ-ONLY role · a generated file here means the schema drifted from the entities.
npx typeorm migration:generate ./tmp/DriftCheck -d ./dist/data-source.js \
  && { echo "drift: entities and database disagree"; exit 1; } \
  || echo "no drift — entities match the live schema"

While a migration is applying, watch the engine for blocking. On PostgreSQL a migration that appears hung is almost always waiting on an ACCESS EXCLUSIVE lock held by a long-running query:

-- PostgreSQL · read-only · run in a second session to see what is blocking the migration.
SELECT pid, wait_event_type, wait_event, state, left(query, 80) AS query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock';
-- MySQL 8.0 · read-only · shows DDL waiting on metadata or row locks during a migration.
SELECT * FROM performance_schema.data_lock_waits;
SHOW ENGINE INNODB STATUS\G

Use the verification list before you call the deploy complete:

Rollback Path

TypeORM does generate a down(), but a generated down() is a liability, not a safety net — it frequently proposes to DROP the very columns a backfill just populated. Reversal is a deliberate, authored decision with two distinct cases.

Case A — the migration failed mid-flight. On PostgreSQL, with the default transaction: "all" (or "each"), the transaction already rolled the DDL back and no ledger row was written, so the schema is untouched and you simply fix the migration and re-run. Confirm the ledger holds no partial record before retrying:

# bash · production · migration role · confirms nothing half-applied was recorded before you retry.
npx typeorm migration:show -d ./dist/data-source.js
npx typeorm migration:run -d ./dist/data-source.js

On MySQL the DDL may have partially applied because each ALTER TABLE implicitly commits; verify the real table state with SHOW CREATE TABLE and hand-revert any partial change before re-running, since TypeORM cannot roll it back for you.

Case B — the migration succeeded but the deploy must be reversed. The safe condition is strict: reversal must be non-destructive. The correct move under Expand and Contract Methodology is to redeploy the previous application image and leave the expanded schema in place — the old code tolerates the extra column, so there is no need to touch the database at all. Only run migration:revert when the migration added an object nothing now reads and you have confirmed its down() drops only that object:

# bash · production · migration role · reverts EXACTLY ONE migration (the newest recorded) and deletes its ledger row.
# Safe ONLY when down() drops objects this migration added and nothing reads. NEVER for a column a backfill filled.
npx typeorm migration:revert -d ./dist/data-source.js

If you must reverse a change whose generated down() is unsafe, ignore it and run a pre-authored inverse script instead, then reconcile the ledger so history stays honest:

-- PostgreSQL · migration role · pre-authored inverse · safe ONLY for objects this migration added and nothing reads.
BEGIN;
DROP INDEX IF EXISTS idx_users_status;
ALTER TABLE users DROP COLUMN IF EXISTS status_flag;
DELETE FROM migrations WHERE name = 'AddUserStatus1721800000000';
COMMIT;
Choosing a rollback path Root question: did migration:run fail? A failure branches by engine into Postgres retry versus MySQL hand-revert; a success that must be reversed branches into the default safe redeploy of the previous image versus a narrow migration:revert gated on a non-destructive down. Choosing a Rollback Path migration:run failed? Failed mid-flight which engine? Succeeded, but deploy is bad? PostgreSQL txn rolled back — schema untouched. fix & retry MySQL 8.0 DDL auto-committed. SHOW CREATE TABLE, hand-revert & retry Default safe path redeploy previous image; leave the schema expanded migration:revert narrow case only — non-destructive down(), nothing reads yes — failed no — succeeded
Reversal is engine-aware: a clean failure just retries, but a succeeded deploy rolls back by swapping the image, not by dropping columns.

Common Errors & Fixes

  • No changes in database schema were found - cannot generate a migration. migration:generate diffed the entities against the target and found them identical. Root cause: you edited an entity but did not rebuild, so the CLI read stale compiled JS; or you are pointed at a database that already has the change. Fix by running your build step before migration:generate and confirming the -d DataSource points at the intended database.
  • QueryFailedError: column "status_flag" of relation "users" already exists during migration:run. Root cause: the migration was applied out of order, or applied twice because a hand-run left the schema changed but the ledger row missing. Fix by inspecting the migrations ledger, making the up() idempotent with IF NOT EXISTS, and reconciling the ledger; the ordering variant is the subject of resolving TypeORM out-of-order migration errors.
  • QueryRunnerAlreadyReleasedError: Query runner already released. A migration used queryRunner after awaiting something that released it, common when mixing CREATE INDEX CONCURRENTLY with a wrapping transaction. Fix by setting transaction: "none" for that migration and issuing the concurrent statement on its own, since a concurrent index cannot run inside the batch transaction.
  • Unable to connect to the database... too many clients already / pool exhaustion. The migration:run step and the rolling application fleet contended for the same connection budget. Root cause: no reserved connections for the migration role, or a pooler in transaction mode. Fix by giving the migration a dedicated small pool and running it as a discrete step before the app rolls, the same contention pattern the ORM & Framework Migration Workflows guide names across frameworks.
  • Entity metadata for User#status_flag was not found. The entity is not registered in the DataSource entities glob, or the build did not emit it. Fix by correcting the entities path to match the compiled output and rebuilding before generating.

Child Page Index

This section drills into the three situations that most often stall a TypeORM deploy. The guide on disabling TypeORM synchronize in production is where every team should start: it walks through why boot-time synchronize: true silently drops columns, how to prove it is off in every environment, and how to baseline an existing auto-synced database into a clean migration history without a maintenance window. The guide on resolving TypeORM out-of-order migration errors covers the timestamp-ordering trap — why a migration merged from a long-lived branch runs after a newer one already changed the schema, how to read the migrations ledger to confirm it happened, and how to renumber or reconcile safely. The guide on generating safe TypeORM migrations from entities covers reading the generated up() and down() before you trust them — spotting a rename rendered as drop-plus-add, splitting a destructive diff into expand and contract steps, and hardening the SQL with idempotent guards. For the parallel mechanics in another framework, the Prisma Migration Strategies guide is the sibling reference.

Up one level: ORM & Framework Migration Workflows.

Frequently Asked Questions

Should I ever leave synchronize: true on outside local development? No. synchronize: true reconciles the database to your entities on every application boot, with no reviewable SQL, no history, and no way to roll back — a renamed field becomes a DROP COLUMN plus an ADD COLUMN that silently destroys data. It is acceptable only against a throwaway local database you can recreate at will. For anything shared, set synchronize: false and migrationsRun: false, and apply reviewed migration classes explicitly; the full baseline procedure is in disabling TypeORM synchronize in production.

Why did an older migration run after a newer one and break the schema? TypeORM decides what to run by comparing each migration class’s timestamp against the migrations ledger, then executes every unrecorded class in ascending timestamp order. When a lower-timestamp migration is merged from a long-lived branch after a higher-timestamp one already applied, TypeORM runs the older one late — out of the order it was authored in — and it fails if it assumes objects a newer migration already changed. Read the ledger to confirm, then reconcile as described in resolving TypeORM out-of-order migration errors.

Can I trust the down() that migration:generate writes for rollback? Treat it as a draft, not a safety net. The generated down() mechanically inverts the up(), which means it will happily DROP a column that a backfill has since populated — destroying data on reversal. Under expand-and-contract, the safe rollback is to redeploy the previous application image and leave the expanded schema in place; run migration:revert only when the migration added an object nothing reads and you have verified its down() by hand.

How do I keep the migration step from exhausting my connection pool? Reserve a small, dedicated connection budget for the migration role and run migration:run as a discrete step that finishes before the application fleet rolls. Through a transaction-mode pooler such as PgBouncer, point the migration at a direct connection or a dedicated pool so it does not contend with the live fleet, which otherwise surfaces as too many clients already mid-deploy.