Node.js has no single standard for database migrations, and the five tools most projects end up with take five different approaches: Knex and Sequelize run hand-written JavaScript functions, TypeORM and MikroORM run TypeScript classes that can be generated from your entities, and Drizzle generates plain SQL files with a JSON journal next to them. This article puts the same small schema through each of them and compares how they track, order and run migrations.
The five tools in one paragraph#
Knex is a query builder with a migration runner; migrations are JavaScript modules exporting up and down. Sequelize is an ORM whose CLI runs migrations of the same shape, written against queryInterface. TypeORM is a decorator-based ORM that can diff your entities against a live database and write a migration class for you. MikroORM is also decorator-based, generates migrations from a snapshot of your entity metadata, and emits raw SQL through addSql. Drizzle defines the schema in TypeScript and has drizzle-kit generate numbered .sql files plus a meta/_journal.json that lists them.
What a migration file looks like#
The same table, owners of a veterinary clinic, written for each tool. The differences in shape tell you most of what you need to know about the tool's philosophy.
Knex#
exports.up = async function up(knex) {
await knex.schema.createTable('owners', (table) => {
table.integer('id').notNullable();
table.string('first_name', 50).notNullable();
table.string('last_name', 50).notNullable();
table.string('telephone', 20);
table.primary(['id'], 'pk_owners');
});
};
exports.down = async function down(knex) {
await knex.schema.dropTableIfExists('owners');
};
Sequelize#
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('owners', {
id: { type: Sequelize.INTEGER, allowNull: false, primaryKey: true },
first_name: { type: Sequelize.STRING(50), allowNull: false },
last_name: { type: Sequelize.STRING(50), allowNull: false },
telephone: { type: Sequelize.STRING(20), allowNull: true },
});
},
async down(queryInterface) {
await queryInterface.dropTable('owners');
},
};
TypeORM#
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateOwners1704067201000 implements MigrationInterface {
name = 'CreateOwners1704067201000';
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)
)`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE owners`);
}
}
MikroORM#
import { Migration } from '@mikro-orm/migrations';
export class Migration20240101000001_create_owners extends Migration {
override up(): void {
this.addSql(`create table owners (
id int not null,
first_name varchar(50) not null,
last_name varchar(50) not null,
telephone varchar(20) null,
constraint pk_owners primary key (id)
)`);
}
override down(): void {
this.addSql(`drop table owners`);
}
}
Drizzle#
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)
);
--> statement-breakpoint
CREATE TABLE types
(
id INT NOT NULL,
name VARCHAR(80) NOT NULL,
CONSTRAINT pk_types PRIMARY KEY (id)
);
Drizzle's file is not complete on its own. A meta/_journal.json next to it lists every migration with an index, a timestamp and a tag matching the file name, and the runner reads the journal, not the directory.
How each tool tracks what has run#
Every tool keeps a table in the target database that records which migrations were applied, and every tool orders migrations by something embedded in the file name. The details differ enough to matter when you rebase a branch or restore a backup.
| Tool | History table | Ordering | Rollback unit | Locking |
|---|---|---|---|---|
| Knex | knex_migrations | File name (timestamp prefix), sorted | Batch (all migrations of the last run) | knex_migrations_lock table |
| Sequelize | SequelizeMeta | File name, sorted | One migration, or down to a named one | None built in |
| TypeORM | migrations | Timestamp in the class name | One migration per revert | None built in |
| MikroORM | mikro_orm_migrations | File name, sorted | One migration, or down to a target | None built in |
| Drizzle | __drizzle_migrations | Journal order, compared by timestamp | No down migrations | None built in |
Two rows deserve a closer look. Knex's batch concept means migrate:rollback undoes everything applied in the last migrate:latest, which is convenient in development and surprising in production when a batch contained ten migrations. Drizzle has no down migrations at all: the project's position is that rollbacks are new forward migrations, so if you need a scripted undo you write it yourself.
The history tables are also the tables you must remember to hide from anything that documents your schema. A diagram that shows knex_migrations_lock as a domain entity is a small embarrassment that happens surprisingly often.
Generated or hand-written#
This is the axis on which the tools differ most, and it is a genuine trade-off rather than a maturity gap.
- Knex and Sequelize expect you to write every migration by hand. There is no built-in diff against your models. You get full control and full responsibility; renames, data backfills and partial indexes are all written explicitly, and nothing is generated that you did not think about.
- TypeORM offers
migration:generate, which connects to a database, compares it with the entity classes and writes theupanddownqueries. It needs a database that is already at the state of the previous migration, which in practice means your local development database. - MikroORM generates from a snapshot file of the entity metadata kept in the migrations directory, so it does not need a database to compute the diff, and it notices when two developers generated conflicting migrations from the same snapshot.
- Drizzle also diffs snapshots, kept under
meta/, against the TypeScript schema. Generation is the primary workflow; hand-written migrations are supported throughdrizzle-kit generate --custom.
Generated migrations are faster to produce and easier to keep in sync with the model. Hand-written ones are easier to review, because the reviewer reads exactly the SQL or builder calls that will run. Whichever you choose, generated SQL must still be read before it is committed: every generator turns a rename into a drop and an add unless told otherwise.
TypeScript support#
TypeORM, MikroORM and Drizzle are TypeScript-first. TypeORM and MikroORM migrations are TypeScript classes, and both projects document running them through a loader such as tsx or ts-node in development and from compiled JavaScript in production. Drizzle's schema is TypeScript but its migrations are SQL, so the runtime question disappears: the migrator only needs the folder. Knex supports TypeScript migration files when a loader is registered, and Sequelize's CLI is JavaScript-oriented; TypeScript projects typically compile migrations first or keep them in plain JavaScript.
The practical rule: decide early whether production runs .ts or .js migration files, and make the glob in your configuration match. A large share of "no pending migrations" reports in production come from a pattern that points at src/**/*.ts in an image that only contains dist/**/*.js.
Transactions and partial failures#
Knex wraps each migration in a transaction unless disableTransactions is set. TypeORM's migrationsTransactionMode defaults to running all pending migrations in one transaction, with each and none as alternatives. MikroORM runs migrations transactionally where the driver allows it. Whether a transaction actually protects you depends on the engine: PostgreSQL rolls DDL back, MySQL and MariaDB commit each DDL statement implicitly, so a failed migration on MySQL leaves half its work behind regardless of what the tool asked for. Transactional DDL explained goes through the consequences engine by engine.
Which one should you pick#
- You already use one of these ORMs: use its migrations. Mixing an ORM with a foreign migration tool doubles the number of places the schema is described.
- You want SQL you can read in a pull request and no ORM: Knex migrations, or Drizzle if you are willing to define the schema in TypeScript and let it generate the SQL.
- You want generation from a model without touching a database during generation: MikroORM or Drizzle.
- You are on a legacy Sequelize codebase: stay, but adopt a naming and review convention, because the CLI gives you nothing to lean on. The migration anti-patterns article lists the habits worth banning.
Where SchemaDrawer fits#
All five tools are supported by SchemaDrawer, which runs your migration files, unchanged, against a throw-away database in Docker and returns an SVG diagram of the tables, columns, primary keys and foreign keys they create, with the history tables left out. Upload the migrations directory with migrationType set to KNEX, SEQUELIZE, TYPEORM, MIKRO_ORM or DRIZZLE; the connection configuration is supplied by the service, so only the migration files travel. Its examples use JavaScript files for Knex and Sequelize, TypeScript classes for TypeORM and MikroORM, and the SQL files plus meta/_journal.json for Drizzle. The per-tool pages, starting with Knex, list the engines each tool can be drawn on; TypeORM, Knex and Sequelize reach all six, while Drizzle has no SQL Server driver and MikroORM is drawn on PostgreSQL, MySQL, MariaDB and SQL Server.
Key takeaways#
- Knex and Sequelize: hand-written JavaScript, simple and explicit; Knex rolls back by batch.
- TypeORM: generated or hand-written TypeScript classes; generation needs a live database.
- MikroORM: generated from a metadata snapshot, raw SQL inside classes.
- Drizzle: generated SQL files plus a journal; no down migrations.
- Whatever you pick, read the generated SQL, decide on ts versus js in production, and hide the history table from your documentation.