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
00

Key terms in plain words

New to databases or migrations? Start here. Every word below shows up later on this page, each compared to a records office that has to stay open while its ledgers are re-ruled. Underlined words in the modules link back to these cards.

22 terms in 5 groups. Hover an underlined word anywhere on the page for a quick reminder, or click it to jump here.

What you're changing

The database is a busy records office. Its ledgers are the tables, and the floor plan says which ledgers exist and how each is ruled.

Database
The program and the stored data your app reads and writes. This page uses PostgreSQL, a popular free one. It is shared by every copy of your app, so a change to it affects them all at once.
Table
Where data lives: a grid of rows and columns about one kind of thing. Each row is one record, such as one contact, and each column is one piece of information about it, such as phone.
For example The contacts table has a row per contact and a column per detail.
Schema
The shape of the database: its tables, their columns, the type of each column and the rules on them. A migration is how you change the schema.
ORM
Object relational mapper: a library that lets your code work with objects while it writes the SQL, the database's language, for you. TypeORM is the ORM used on this page, and it also runs migrations.
Entity
A class in your code that describes a table: its name, its columns and their types. TypeORM can compare your entities with the real database and write the SQL to close the gap.

Migrations themselves

Numbered work orders for the office, plus the logbook that says which ones are done.

Migration
A small file of code that changes the database schema one step. Its name starts with a timestamp, so every machine applies the same work orders in the same order.
For example 1727600000000-AddPhoneE164.ts
up and down
Every migration has two methods. up() makes the change and down() reverses exactly that change. Both receive a QueryRunner, the object that sends SQL to the database.
Migrations table
A table TypeORM keeps in your database with one row per migration that has run. That's how it knows which ones are still pending.
DataSource and the CLI
The DataSource file holds the database address and settings. The CLI (command line tool, a program you run by typing in a terminal) reads the same file, so your app and your migration commands never disagree about which database they mean.
Generate or create
migration:generate compares your entities with the database and writes the SQL for you, so read it before trusting it. migration:create gives you an empty migration to fill in yourself, for data changes and careful steps the comparison can't know about.

Why a quick change can stall a site

A one-second edit can freeze every visitor if it has to wait its turn at the counter.

Transaction
A group of database changes that succeed or fail as one, so a half finished migration never sticks. TypeORM wraps each migration in one by default.
Lock
A claim a statement takes on a table so others can't change it at the same moment. Locks come in strengths. ACCESS EXCLUSIVE, the strongest, blocks even reads, and most ALTER TABLE changes need it.
Lock queue
Locks are handed out in order. If your migration waits behind a slow report, every query after it waits too, the app's connection pool (its small set of open database connections) runs dry, and the site looks down.
lock_timeout
A setting that makes a statement give up if it can't get its lock in time. The migration fails fast, the queue behind it drains, and you retry later.
For example SET LOCAL lock_timeout = '3s'
Index
A sorted list that points straight to matching rows, so lookups don't read the whole table. A normal build blocks writes; CREATE INDEX CONCURRENTLY keeps the table open but can't run inside a transaction.

Changing a busy table safely

Splitting one risky change into small steps the office can absorb while customers keep coming.

Backfill
Writing values into a new column for rows that existed before it. Done in small batches, each in its own short transaction, so no lock is held for long and a rerun carries on where it stopped.
NOT NULL and CHECK NOT VALID
NOT NULL means a column must always have a value. Adding it directly scans the whole table under the strongest lock. Adding a CHECK rule as NOT VALID first, then validating it, does the slow scan under a weak lock instead.
Expand and contract
Change a table in safe steps spread over releases: add the new column, write to both, backfill, switch reads over, and only then drop the old one. Old and new code both keep working at every step.

Shipping it

Running the work orders in the right order on release day, and knowing how to back out.

Deploy order
A deploy, or release, puts a new version of the app live. Migrations run first, so the code that needs a column never arrives before the column does.
Revert
migration:revert runs down() for the newest migration that has run, one per call. It's only safe when down() truly reverses up().
synchronize
A TypeORM setting that alters the database to match your entities every time the app starts. It can drop a column and its data silently, so keep it false anywhere but a throwaway database.
Idempotent
Safe to run again with the same result. IF NOT EXISTS makes a step skip itself when it's already done, which rescues a migration that half applied.
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