TypeORM CREATE INDEX CONCURRENTLY: transaction false is not enough by itself

TypeORM wraps migrations in one transaction by default, which makes PostgreSQL reject CREATE INDEX CONCURRENTLY. Here is the exact transaction mode and migration shape that works.

The working TypeORM pattern has two parts:

  1. Run migrations with transaction mode each or none.
  2. Set transaction = false on the migration that contains CREATE INDEX CONCURRENTLY.

Setting only the class property is not enough when TypeORM is still using its default all mode.

import { MigrationInterface, QueryRunner } from "typeorm"

export class AddUserEmailIndex1758099600000 implements MigrationInterface {
  transaction = false

  async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_user_email"
      ON "user" ("email")
    `)
  }

  async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      DROP INDEX CONCURRENTLY IF EXISTS "IDX_user_email"
    `)
  }
}

Then use a compatible runner mode. In the data source:

export default new DataSource({
  type: "postgres",
  migrations: ["dist/migrations/*.js"],
  migrationsTransactionMode: "each",
})

Or pass it when running migrations:

typeorm migration:run --transaction each -d dist/data-source.js

TypeORM’s transaction mode documentation is precise on the important edge: the per-migration transaction property works only in each or none mode. TypeORM’s default is all, which wraps the whole migration run in one transaction.

Why PostgreSQL rejects the default

A normal index build takes a SHARE lock. Reads continue, but INSERT, UPDATE, and DELETE wait for the build to finish. PostgreSQL offers CREATE INDEX CONCURRENTLY for live tables because it allows normal writes to continue.

The tradeoff is that a concurrent build uses multiple internal transactions and scans. PostgreSQL therefore rejects it inside an explicit transaction block. The PostgreSQL CREATE INDEX documentation states the distinction directly: regular CREATE INDEX can run in a transaction block, while CREATE INDEX CONCURRENTLY cannot.

With TypeORM’s default mode, this migration:

await queryRunner.query(`
  CREATE INDEX CONCURRENTLY "IDX_user_email" ON "user" ("email")
`)

effectively reaches PostgreSQL inside a wrapper shaped like this:

BEGIN;
CREATE INDEX CONCURRENTLY "IDX_user_email" ON "user" ("email");
COMMIT;

PostgreSQL returns:

ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block

The easy mistake: class property plus default runner mode

This looks correct but is not:

export class AddUserEmailIndex implements MigrationInterface {
  transaction = false
  // ...
}

If the migration command still uses --transaction all, TypeORM cannot honor a per-migration opt-out because the transaction already wraps the full run. Change the runner to each or none first.

each is usually the narrower choice. Ordinary migrations keep one transaction each, while the concurrent-index migration opts out. none disables automatic wrapping across the run and moves more transaction responsibility into individual migrations.

Keep the migration focused

A concurrent index migration should contain the index operation and the session safeguards it actually needs. Do not mix unrelated schema changes into the same class. Once the migration opts out of automatic transaction wrapping, a later failure cannot roll back earlier successful statements as one unit.

Use a short lock timeout so the operation does not wait behind an old transaction long enough to create a queue:

await queryRunner.query(`SET lock_timeout = '2s'`)
await queryRunner.query(`SET statement_timeout = '30min'`)
await queryRunner.query(`
  CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_user_email"
  ON "user" ("email")
`)

The statement_timeout must be long enough for the two scans and transaction waits. A value copied from a short OLTP query timeout can cancel a healthy build halfway through.

Check both the SQL and the wrapper

Run pgfence against the TypeORM source before the migration reaches the database:

pgfence analyze --format typeorm --ci --max-risk medium src/migrations/*.ts

pgfence extracts literal queryRunner.query() calls from up(). It also recognizes transaction = false, so it can avoid reporting a false transaction warning for a migration that really runs outside TypeORM’s wrapper. Dynamic SQL and unsupported builder calls remain visible in the coverage line.

The SQL check and the runner configuration solve different problems. The SQL must say CONCURRENTLY. The migration runner must allow PostgreSQL to execute it outside a transaction block. Review both.

For the lock behavior and failed-build cleanup, read CREATE INDEX CONCURRENTLY in a transaction is a silent footgun and CREATE INDEX CONCURRENTLY IF NOT EXISTS does not fix a broken index.

← All posts