TypeORM Migrations Guide: Generate, Run and Avoid synchronize

TypeORM migrations are TypeScript classes with an up and a down method, generated by diffing your entities against a database or written by hand, and applied in timestamp order with a history kept in a migrations table. The tool works well once three decisions are made explicitly: how the CLI finds your DataSource, whether production runs .ts or compiled .js files, and that synchronize stays off everywhere except a local sandbox.

The DataSource is the configuration#

Since TypeORM 0.3 there is no ormconfig file; the CLI and the application share one DataSource object. A minimal one for a PostgreSQL project looks like this:

import 'reflect-metadata';
import { DataSource } from 'typeorm';

export const AppDataSource = new DataSource({
  type: 'postgres',
  url: process.env.DATABASE_URL,
  entities: ['src/entity/**/*.ts'],
  migrations: ['src/migration/**/*.ts'],
  migrationsTableName: 'migrations',
  synchronize: false,
  logging: ['migration'],
});

Every migration command takes -d path/to/data-source.ts. Because the file is TypeScript, the CLI has to be started through a loader. The project's documentation shows the typeorm-ts-node-commonjs and typeorm-ts-node-esm wrappers; a loader such as tsx works as well:

npx typeorm-ts-node-commonjs migration:run -d src/data-source.ts
# or
node --import tsx ./node_modules/typeorm/cli.js migration:run -d src/data-source.ts

migration:generate versus migration:create#

migration:create writes an empty class with the right name and timestamp for you to fill in. migration:generate connects to the database named in the DataSource, compares its schema with your entity classes, and writes the queries that would bring the database in line, together with the reverse queries for down:

npx typeorm-ts-node-commonjs migration:generate src/migration/AddVisits -d src/data-source.ts
npx typeorm-ts-node-commonjs migration:create   src/migration/BackfillOwnerPhone

Generation has one precondition people forget: the database it connects to must already be at the state of the last committed migration. Generate against an empty database and you get a migration that recreates everything; generate against a database with an unrelated experiment in it and that experiment's reversal ends up in your migration. Run migration:run first, then migration:generate. When there is nothing to generate the command says so and exits with a non-zero code, which is useful in CI as a cheap check that entities and migrations agree.

The shape of a migration#

Generated or not, every migration is a class implementing MigrationInterface. The timestamp at the end of the class name is what TypeORM sorts by, and the name property is what it records in the history table:

import { MigrationInterface, QueryRunner } from 'typeorm';

export class CreateOwnersAndPets1704067201000 implements MigrationInterface {
  name = 'CreateOwnersAndPets1704067201000';

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`CREATE TABLE owners (
      id         INT          NOT NULL,
      first_name VARCHAR(50)  NOT NULL,
      last_name  VARCHAR(50)  NOT NULL,
      telephone  VARCHAR(20),
      CONSTRAINT pk_owners PRIMARY KEY (id)
    )`);
    await queryRunner.query(`CREATE TABLE pets (
      id       INT         NOT NULL,
      name     VARCHAR(50) NOT NULL,
      owner_id INT         NOT NULL,
      CONSTRAINT pk_pets PRIMARY KEY (id),
      CONSTRAINT fk_pets_owners FOREIGN KEY (owner_id) REFERENCES owners (id)
    )`);
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`DROP TABLE pets`);
    await queryRunner.query(`DROP TABLE owners`);
  }
}

Raw SQL through queryRunner.query is what the generator emits and what most teams keep. The QueryRunner also offers a schema builder API (createTable, addColumn, createForeignKey) that produces engine-specific DDL, which matters if the same migrations must run on more than one database engine.

TypeScript in development, JavaScript in production#

The single most common production failure with TypeORM migrations is a glob that does not match. In development the DataSource points at src/migration/*.ts. The production image contains dist/migration/*.js. If the same DataSource file is shipped, migration:run finds no files, reports that no migrations are pending, and the application starts against an outdated schema.

Two patterns solve it. Either the glob is derived from the environment:

const isCompiled = __filename.endsWith('.js');
migrations: [isCompiled ? 'dist/migration/*.js' : 'src/migration/*.ts'],

or migrations are imported explicitly and listed as classes, which survives bundlers as well:

import { CreateOwnersAndPets1704067201000 } from './migration/1704067201000-create-owners-and-pets';
migrations: [CreateOwnersAndPets1704067201000],

Running migrations: CLI, startup or migrationsRun#

migration:run applies all pending migrations. By default they run inside one transaction (migrationsTransactionMode: 'all'); 'each' gives every migration its own transaction and 'none' disables them, which you need for statements that cannot run in a transaction, such as CREATE INDEX CONCURRENTLY on PostgreSQL. Whether a transaction protects you at all depends on the engine, as the article on transactional DDL explains.

Setting migrationsRun: true on the DataSource makes the application apply pending migrations when it initializes. It is convenient for a single instance and risky for several: two replicas starting at once will both try to migrate, and TypeORM does not take a lock. A dedicated migration step in the deployment pipeline, run once before the new version starts, is the safer default.

migration:revert undoes one migration, the most recent, per invocation; there is no "revert to version" command. migration:show lists what has and has not run, marking applied migrations with an X.

Why synchronize must stay off#

synchronize: true tells TypeORM to alter the database on startup until it matches the entities. It is the fastest way to prototype and one of the fastest ways to lose data. When a property is renamed, TypeORM sees a column that disappeared and a column that appeared, and it drops the first. When an entity is removed, its table is dropped. When a column type changes, the column is altered or recreated. None of this is reviewed, none of it is recorded, and all of it happens the moment a container starts. Keep it on for a local database you can recreate and nowhere else. If you want a safety net against forgetting, fail fast at startup when synchronize is true and NODE_ENV is production.

Errors you will meet#

  • No changes in database schema were found after editing an entity: the entity file is not matched by the entities glob, or the database is already ahead of the migrations.
  • No migrations are pending in production: the migrations glob points at .ts files in a compiled build.
  • QueryFailedError: relation already exists: the history table was lost or the database was copied without it, so TypeORM replays from the start. Compare the migrations table with the files before doing anything else.
  • Migrations out of order after merging branches: a migration with an older timestamp than the last applied one is still applied by TypeORM, which sorts all pending migrations, but its SQL may assume a state that a newer migration has already changed. Re-timestamp the migration when you rebase.

Where SchemaDrawer fits#

A TypeORM migrations folder is executable on its own: the classes need nothing but a database. SchemaDrawer takes advantage of that. Upload the migration files with migrationType=TYPEORM and the engine and version you deploy to; the service supplies its own DataSource pointing at a throw-away database in Docker, runs migration:run with the TypeScript files loaded through tsx, reads the resulting tables, columns, primary keys and foreign keys back over JDBC and returns an SVG diagram, with the migrations history table left out. TypeORM can be drawn on PostgreSQL, MySQL, MariaDB, SQL Server, Oracle and SQLite. The TypeORM page shows the layout of a working upload and a GitHub Action step that keeps the diagram current in your repository.

Key takeaways#

  • One DataSource, passed with -d, configures both the application and the CLI.
  • Run pending migrations before migration:generate, and read what it generates.
  • Make the migrations glob match what production actually contains.
  • Prefer a pipeline step over migrationsRun when you run more than one instance.
  • synchronize is a development convenience that drops columns; keep it off elsewhere.
Marek Hudyma
Builds SchemaDrawer, a service that draws database diagrams by running your migrations on a real engine. About
Keep reading

Related articles

Draw your schema in the next five minutes

One HTTP call with your migration files, one poll, one SVG. Free while we build it.