TypeORM Migrations 0/7

Backend cheatsheetTypeORM migrationsShip the schema safely

TypeORM
Migrations

Generate, create, run and revert, then the PostgreSQL rules that keep a busy table online while it changes: short lock timeouts, batched backfills and concurrent indexes.

TypeORM 0.3PostgreSQL7 min read
01

Set up the CLI

One DataSource file that both the app and the CLI read, and four scripts you will run every week.

SetupDataSourceCLIts-node
data-source.tsTypeScript
export default new DataSource({
  type: 'postgres',
  url: process.env.DATABASE_URL,
  entities: ['dist/**/*.entity.js'],
  migrations: ['dist/migrations/*.js'],
  migrationsTableName: 'migrations',
  migrationsTransactionMode: 'each', // one transaction per migration, not one for all
  synchronize: false,                // never true outside a throwaway database
});

Why it matters: the CLI and the app agree on one config, and synchronize can never silently drop a column in production.

package.jsonJSON
{
  "scripts": {
    "typeorm": "typeorm-ts-node-commonjs -d src/data-source.ts",
    "migration:generate": "npm run typeorm -- migration:generate",
    "migration:run": "npm run typeorm -- migration:run",
    "migration:revert": "npm run typeorm -- migration:revert",
    "migration:show": "npm run typeorm -- migration:show"
  }
}

Why it matters: the data source flag lives in one place, so nobody runs a migration against the wrong config.

CommandWhat it does
migration:generate src/migrations/AddPhoneDiffs entities against the database and writes the SQL for you
migration:create src/migrations/BackfillAn empty migration you fill by hand; needs no database
migration:runRuns every pending migration, oldest first
migration:revertRuns down() of the last executed migration, one at a time
migration:showLists migrations, with [X] for executed
02

Anatomy of a migration

A class with a timestamp in its name, an up that moves forward and a down that undoes exactly that.

BasicsQueryRunnerup and downmigrations table
1727600000000-AddPhoneE164.tsTypeScript
export class AddPhoneE1641727600000000 implements MigrationInterface {
  name = 'AddPhoneE1641727600000000';

  async up(q: QueryRunner): Promise<void> {
    await q.query(`SET LOCAL lock_timeout = '3s'`);
    await q.query(`ALTER TABLE contacts ADD COLUMN phone_e164 text`);
  }

  async down(q: QueryRunner): Promise<void> {
    await q.query(`ALTER TABLE contacts DROP COLUMN phone_e164`);
  }
}

Why it matters: the timestamp orders migrations, and a down that truly reverses up is what makes revert safe.

migrations tableid1timestamp1727600000000name'AddPhoneE1641727600000000'
one row per executed migrationrevert deletes the last row
TerminalTerminal
$ npm run migration:show
[X] 1 CreateContacts1727000000000
[X] 2 AddPhoneE1641727600000000
[ ] 3 ContactsPhoneNotNull1727700000000
# one pending migration: run it before deploying code that needs it
03

Generate or create

Generate when entities changed and you want the diff. Create when the change is data, or needs care the diff cannot know.

WorkflowgeneratecreateReview
Generatemigration:generate AddPhoneCompares entities with the live schema and writes the SQL. Always read it: it happily drops and recreates.
Createmigration:create BackfillAn empty class. For backfills, CONCURRENTLY indexes, CHECK constraints and anything order sensitive.
TerminalTerminal
$ npm run migration:generate -- src/migrations/AddPhoneE164
Migration /app/src/migrations/1727600000000-AddPhoneE164.ts has been generated successfully.
$ npm run migration:generate -- src/migrations/Nothing
No changes in database schema were found - cannot generate a migration.
# generate only works when entities and schema differ
Generated SQL looks likeIt meansDo instead
DROP COLUMN then ADD COLUMNYou renamed a propertyExpand and contract, or a hand written RENAME
ALTER COLUMN ... TYPEA type change that may rewrite the tableNew column, dual write, backfill, swap
SET NOT NULLA full scan under ACCESS EXCLUSIVECHECK NOT VALID, VALIDATE, then SET NOT NULL
CREATE INDEXWrites blocked while it buildsCREATE INDEX CONCURRENTLY in its own migration
04

Know the lock

Most ALTERs finish in milliseconds. The danger is the lock they wait for, and the queue that forms behind them.

PostgreSQLMust knowlock_timeoutLock queue
  1. Long SELECTholds ACCESS SHARE for 40s
  2. Your ALTERwaits for ACCESS EXCLUSIVE
  3. Every new queryqueues behind the ALTER
  4. Pool exhaustedthe site looks down

Locks are granted in order, so one migration waiting behind a slow report makes every later query wait too. Set a short lock_timeout in every migration so it gives up quickly, lets the queue drain, and can be retried.

ChangeLockWorkSafe way on a busy table
ADD COLUMN, nullableACCESS EXCLUSIVEInstantSafe with lock_timeout
ADD COLUMN with constant DEFAULTACCESS EXCLUSIVEInstant on PG 11+Safe with lock_timeout
ADD COLUMN with volatile DEFAULTACCESS EXCLUSIVERewrites tableAdd nullable, backfill, then SET DEFAULT
ALTER COLUMN TYPE int to bigintACCESS EXCLUSIVERewrites table and indexesNew column, dual write, backfill, swap
SET NOT NULLACCESS EXCLUSIVEFull scanCHECK NOT VALID, VALIDATE, SET NOT NULL
CREATE INDEXSHAREBlocks writes while buildingCREATE INDEX CONCURRENTLY
ADD FOREIGN KEYSHARE ROW EXCLUSIVE, both tablesScans rowsNOT VALID, then VALIDATE CONSTRAINT
RENAME or DROP COLUMNACCESS EXCLUSIVEInstantOnly after no running code uses it
05

A required column

Nullable first, backfill in batches, then NOT NULL with a validated CHECK doing the slow part under a weak lock.

RecipeBackfillCHECK NOT VALIDPG 12+
  1. ExpandADD COLUMN phone_e164 text
  2. Dual writeapp writes both columns
  3. Backfillbatches of 5,000
  4. EnforceCHECK, VALIDATE, NOT NULL
  5. Contractdrop phone next release
1727700000000-ContactsPhoneNotNull.tsTypeScript
async up(q: QueryRunner) {
  await q.query(`SET LOCAL lock_timeout = '3s'`);
  // instant: only rows written from now on are checked
  await q.query(`ALTER TABLE contacts ADD CONSTRAINT phone_e164_nn CHECK (phone_e164 IS NOT NULL) NOT VALID`);
  // full scan, but under SHARE UPDATE EXCLUSIVE: reads and writes continue
  await q.query(`ALTER TABLE contacts VALIDATE CONSTRAINT phone_e164_nn`);
  // instant on PG 12+: the valid CHECK already proves it
  await q.query(`ALTER TABLE contacts ALTER COLUMN phone_e164 SET NOT NULL`);
  await q.query(`ALTER TABLE contacts DROP CONSTRAINT phone_e164_nn`);
}

Why it matters: the only step that reads every row runs under a lock that lets traffic through.

backfill.sqlSQL
UPDATE contacts c SET phone_e164 = normalize_e164(c.phone)
WHERE c.id IN (
  SELECT id FROM contacts WHERE phone_e164 IS NULL
  ORDER BY id LIMIT 5000
  FOR UPDATE SKIP LOCKED
);

Why it matters: each batch is a short transaction, and running it again just picks up where it stopped.

Before you start, estimate how long the backfill will take. Run this with your own row count.

backfill-estimate.jsJavaScript
const rows = 12_480_211;
const batch = 5_000;
const msPerBatch = 180; // measure one batch on a replica first
const pauseMs = 200;    // room for replicas and autovacuum

const batches = Math.ceil(rows / batch);
const minutes = (batches * (msPerBatch + pauseMs)) / 60000;
console.log('batches:', batches.toLocaleString('en-IN'));
console.log('estimated minutes:', minutes.toFixed(1));
console.log('rows per second:', Math.round(batch / ((msPerBatch + pauseMs) / 1000)).toLocaleString('en-IN'));
TerminalOutput
$ node backfill-estimate.js
batches: 2,497
estimated minutes: 15.8
rows per second: 13,158

Why it matters: knowing it takes 16 minutes, not 16 seconds, decides whether it runs in the deploy or as a job.

06

Indexes and transactions

CREATE INDEX CONCURRENTLY cannot run in a transaction, and TypeORM wraps migrations in one. Turn it off for that migration only.

IndexesCONCURRENTLYtransaction = falseInvalid index
1727800000000-ContactsPhoneIndex.tsTypeScript
export class ContactsPhoneIndex1727800000000 implements MigrationInterface {
  transaction = false; // needs migrationsTransactionMode: 'each'

  async up(q: QueryRunner) {
    await q.query(`CREATE INDEX CONCURRENTLY IF NOT EXISTS contacts_phone_e164_idx ON contacts (phone_e164)`);
  }

  async down(q: QueryRunner) {
    await q.query(`DROP INDEX CONCURRENTLY IF EXISTS contacts_phone_e164_idx`);
  }
}

Why it matters: writes never stop while the index builds, and down is just as gentle as up.

migrationsTransactionModeBehaviourUse when
all (default)Every pending migration in one transactionSmall projects; a failure rolls back the whole batch
eachOne transaction per migration; transaction = false opts outProduction, and any CONCURRENTLY index
noneNo transactions at allRarely; a failure leaves partial changes
psqlTerminal
$ SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;
 contacts_phone_e164_idx
# a cancelled concurrent build leaves this behind: drop it concurrently and run again
07

Ship it

Migrations run before the code that needs them, contracts wait a full release, and every failure has a known next step.

ReleaseCI/CDRevertTroubleshooting

Picture it: the deploy order

Buildtsc, imagemigration:showpending?migration:runexpand onlyDeploy approllingBackfill jobbatchedContractnext releaselock_timeout hit: fail fast, retry the pipeline
ErrorCauseFix
canceling statement due to lock timeoutSomething held the table longer than 3sRetry off peak; find the blocker in pg_stat_activity
No changes in database schema were foundEntities already match the schemaUse migration:create for data changes
CREATE INDEX CONCURRENTLY cannot run inside a transaction blockThe migration is in a transactiontransaction = false with mode each
relation "..." already existssynchronize ran, or a half applied migrationTurn synchronize off; make up idempotent with IF NOT EXISTS
column "..." does not exist after deployCode shipped before its migrationRun migrations first in the pipeline
TerminalTerminal
$ npm run migration:revert
Migration ContactsPhoneIndex1727800000000 has been reverted successfully.
# revert undoes one migration per run, newest first

Which one do I need?

Find your change, then follow the column on the right. When in doubt, split it into two releases.

I want toDoCommand
Add an optional columnOne migration with lock_timeoutmigration:generate
Add a required computed columnNullable, backfill, CHECK, NOT NULLmigration:create twice
Add an index to a big tableCONCURRENTLY, transaction offmigration:create
Rename a columnNew column, dual write, swap, drop laterSeveral releases
Change a column typeNew column, backfill, swapSeveral releases
See what is pendingCheck before every deploymigration:show
Undo the last migrationOnly if down is truly reversiblemigration:revert