Skip to content

Database Migrations in NestJS

Aman Kumar Singh8 min read
Part 15 of 40From the NestJS Production Guide series
Database Migrations in NestJS — article by Aman Kumar Singh

The previous article in this NestJS Production Guide series got PostgreSQL and TypeORM talking to each other: a DataSource, an entity or two, a working connection. That's enough to build a prototype. It's not enough to run a production schema, because at some point that schema has to change, and "change" is exactly where most of the pain in a real backend lives.

This article is about the mechanics and the discipline of schema migrations: how TypeORM generates and runs them, why synchronize: true has to disappear the moment real data shows up, and the patterns that keep a migration from taking your API down at 2am. None of this is exotic. Most of it is just being deliberate about a part of the stack that's easy to treat as an afterthought until it breaks something.

I'll use TypeORM's migration tooling throughout since that's what the series has settled on, but the underlying ideas (versioned schema changes, backward-compatible deploys, safe rollbacks) apply just as much if you're using Prisma Migrate, Knex, or raw SQL scripts.

Why synchronize: true has to go

TypeORM's synchronize option compares your entity definitions to the actual database schema and alters tables to match, automatically, on every application startup. It's genuinely useful in early development: add a column to an entity, restart the app, and the column exists. No migration file, no ALTER TABLE. No ceremony.

The problem is that synchronize doesn't know the difference between a development database you can throw away and a production database with paying customers' data in it. It infers schema changes from your code and applies them directly, which means a rename, a type change, or a dropped column gets executed against production the same way it would against a local Docker container. TypeORM's own documentation is explicit that this should never be enabled in production, and there's a concrete reason why: an inferred ALTER TABLE has no idea what to do with existing rows when a NOT NULL column shows up on a table that already has data, and it will pick something, which is rarely the something you wanted.

Migrations replace that inference with an explicit, reviewable, versioned record of every schema change. Each migration is a file with an up method and a down method, checked into source control, run in a known order, and recorded in a table so the database always knows exactly which migrations have been applied. That's the actual point: schema changes become code you review in a pull request, not a side effect of restarting a process.

Setting up TypeORM's migration CLI

TypeORM's CLI needs its own DataSource configuration, separate from the one your application bootstraps with, because the CLI runs outside of Nest's dependency injection container.

// src/database/data-source.ts
import { DataSource } from 'typeorm';
import { config } from 'dotenv';

config();

export const AppDataSource = new DataSource({
  type: 'postgres',
  host: process.env.DB_HOST,
  port: Number(process.env.DB_PORT ?? 5432),
  username: process.env.DB_USERNAME,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  entities: ['dist/**/*.entity.js'],
  migrations: ['dist/database/migrations/*.js'],
  synchronize: false,
  logging: ['error', 'warn'],
});

A few things worth calling out here. Migrations run against compiled JavaScript in dist/, not the TypeScript source, because the CLI executes with Node directly. synchronize is explicitly false, which should also be the case in the DataSource your TypeOrmModule.forRootAsync factory builds for the running application. Wire up the scripts in package.json so the commands stay consistent across the team:

{
  "scripts": {
    "migration:generate": "typeorm-ts-node-commonjs migration:generate -d src/database/data-source.ts",
    "migration:create": "typeorm-ts-node-commonjs migration:create",
    "migration:run": "typeorm-ts-node-commonjs migration:run -d src/database/data-source.ts",
    "migration:revert": "typeorm-ts-node-commonjs migration:revert -d src/database/data-source.ts"
  }
}

migration:generate diffs your entities against the current database state and writes the ALTER TABLE statements it thinks it needs. migration:create gives you an empty file for changes that aren't a simple entity diff: backfilling data, adding a partial index, changing a constraint that TypeORM's diffing doesn't express well. Treat generated migrations as a draft, not a finished product; read every line before it merges.

// src/database/migrations/1703001234567-AddStatusToOrders.ts
import { MigrationInterface, QueryRunner } from 'typeorm';

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

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      ALTER TABLE "orders"
      ADD COLUMN "status" varchar(20) NOT NULL DEFAULT 'pending'
    `);
    await queryRunner.query(`
      CREATE INDEX "IDX_orders_status" ON "orders" ("status")
    `);
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`DROP INDEX "IDX_orders_status"`);
    await queryRunner.query(`ALTER TABLE "orders" DROP COLUMN "status"`);
  }
}

The down method matters more than it looks like it should. It's the difference between a bad migration being a five-minute migration:revert and being a manual SQL session against production while an incident is live.

The expand/contract pattern for zero-downtime changes

The migration above works fine for a table nobody's actively reading and writing during the deploy. It falls apart the moment you have multiple application instances behind a load balancer, which describes almost any production NestJS deployment past the earliest stage. A rolling deploy means old and new application code run against the same database simultaneously for some window of time, and a migration that assumes only one version of the code is running will break one of those two versions.

The fix is a pattern usually called expand/contract, or parallel change: split a breaking schema change into multiple deploys, each of which is backward compatible on its own.

Take renaming a column, which sounds trivial and is actually one of the more common ways teams take an outage:

  1. Expand. Add the new column alongside the old one. Both exist. Old code writes to the old column; it doesn't know the new one exists yet.
  2. Backfill and dual-write. Deploy application code that writes to both columns, and run a migration (or a background job, for large tables) that backfills the new column from existing rows.
  3. Cut over reads. Deploy application code that reads from the new column instead of the old one, while still writing to both.
  4. Contract. Once you've confirmed nothing depends on the old column anymore, drop it in its own migration.
-- Step 1: expand
ALTER TABLE "customers" ADD COLUMN "email_address" varchar(255);

-- Step 2: backfill (batched, not a single statement on a large table)
UPDATE "customers" SET "email_address" = "email" WHERE "email_address" IS NULL;

-- Step 4: contract, in a later deployment
ALTER TABLE "customers" DROP COLUMN "email";

This is more steps and more deployments than a single RENAME COLUMN, and that overhead is exactly why it's worth skipping on a table with no live traffic or in a single-instance staging environment. It's not worth skipping on a production table backing an endpoint that's actively serving requests during the deploy window. The rule I use: if a migration and the code that depends on it can't both be true at the same instant during a rolling deploy, it needs to be split.

Running migrations safely in production

Where migrations actually run is its own decision, and getting it wrong is a common way to end up with two application instances racing to alter the same table.

Running migration:run as a startup step inside every application instance is the most common mistake. Scale to three instances during a deploy and you get three processes attempting the same schema change concurrently, which at best means two of them fail loudly and at worst means a partially applied migration if the failure happens mid-statement. TypeORM's migrations table prevents the same migration from running twice successfully, but "prevents from running twice" is not the same as "handles concurrent attempts gracefully."

The safer pattern is to run migrations as a distinct step, before the new application version starts receiving traffic, and to have exactly one thing running that step, not once per instance.

# .github/workflows/deploy.yml (excerpt)
- name: Run database migrations
  run: npm run migration:run
  env:
    DB_HOST: ${{ secrets.DB_HOST }}
    DB_PORT: ${{ secrets.DB_PORT }}
    DB_USERNAME: ${{ secrets.DB_USERNAME }}
    DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
    DB_NAME: ${{ secrets.DB_NAME }}

- name: Deploy application
  run: ./scripts/deploy.sh

The same idea applies whether the deployment target is ECS, a Kubernetes job, or a plain EC2 instance behind a deploy script: migrations are a separate, single-runner step that completes before the new code goes live, not a side effect of the application boot sequence. It also means a migration failure blocks the deploy cleanly, instead of surfacing as a half-started application that can't reach a table it expects to exist.

Long-running migrations against large tables deserve their own caution. An ALTER TABLE that adds a column with a non-null default can, depending on the PostgreSQL version and the size of the table, rewrite every row and hold a lock that blocks reads and writes for the duration. On a table with meaningful row counts, that lock is the outage, not the schema change itself. Newer PostgreSQL versions have gotten better at avoiding full table rewrites for simple default additions. Still, confirm the behavior for the specific PostgreSQL version in use rather than assuming it. For genuinely large tables, batching the change (add the column nullable, backfill in chunks, add the constraint after) keeps any single statement's lock duration small.

Key takeaways

  • `synchronize: true` infers schema changes from entity code and applies them directly; it's fine for local development and unsafe for any database holding real data.
  • Migrations are explicit, versioned, reviewable schema changes with a corresponding rollback, checked into source control like any other code.
  • The CLI needs its own `DataSource`, pointed at compiled output, separate from the application's runtime configuration.
  • Breaking schema changes need the expand/contract pattern during rolling deploys: add, backfill, cut over, then remove, each as its own deployable step.
  • Run migrations as a single, distinct deploy step before new application code takes traffic, never as a startup side effect inside every instance.
  • Watch for lock duration on large tables; a migration that seems trivial in code can hold a lock long enough to look like an outage.

Frequently asked questions

Should I ever use synchronize: true in a NestJS project?

Only against a local or ephemeral database where losing data doesn't matter, and never against staging or production. Once a database holds anything you'd be upset to lose, migrations are the only safe path for schema changes.

How do I generate a TypeORM migration from entity changes?

Run `migration:generate` against a `DataSource` pointed at a database that reflects the current production schema; TypeORM diffs your entities against that state and writes the resulting SQL. Review the generated file before committing it. Diffing tools are good at detecting structural differences and not good at knowing your intent, so a generated rename often shows up as a drop-and-add instead.

What happens if a migration fails halfway through in production?

That depends on whether the failing statements are wrapped in a transaction. PostgreSQL supports transactional DDL, so a migration that runs its statements inside a single transaction rolls back cleanly on failure. TypeORM wraps each migration in a transaction by default; avoid disabling that unless a specific statement (like `CREATE INDEX CONCURRENTLY`) requires running outside one, and handle that case as its own explicit migration.

Can I run CREATE INDEX CONCURRENTLY inside a TypeORM migration?

Not inside a transaction, and PostgreSQL doesn't allow `CONCURRENTLY` inside a transaction block at all. You'll need to mark that specific migration to run outside TypeORM's default transaction wrapping, and be aware that a concurrent index build can still fail and leave behind an invalid index that needs to be dropped and retried.

How many migrations is too many to keep around?

There's no fixed number, but a project with hundreds of small migrations accumulated over years is a real onboarding cost: a fresh database has to replay all of them in order. Some teams periodically "squash" old migrations into a single baseline once they're confident no environment still needs to apply them individually. That's an optimization for later, not a day-one concern.

Do I need a rollback for every migration?

Write the `down` method, but plan for forward fixes as the primary recovery path in production. A migration that already backfilled data or dropped a column often can't be perfectly reversed without losing information, and by the time you'd need to roll back, the new application code may already depend on the new schema. A follow-up migration that fixes the problem is usually safer and faster than reverting.

Related articles

Transactions and Data Integrity — Aman Kumar Singh
The PostgreSQL Production Checklist — Aman Kumar Singh
The Production Readiness Checklist — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.