How to Migrate from TypeORM to MikroORM in 2026
I ran into another TypeORM issue last week. The query builder was generating incorrect SQL for a complex join, and I spent two hours debugging why leftJoinAndSelect wasn’t loading the nested relations correctly. Again.
That’s when I decided it was finally time to migrate to MikroORM. Here’s what I learned during the migration and why MikroORM 7 made it surprisingly smooth.
The Breaking Point
My TypeORM project had accumulated tech debt over three years:
- Query builder generated 47 queries for what should have been 3 joins
- Type inference failed on complex queries, requiring manual type casts
- Schema migrations occasionally produced incorrect DDL
- The last stable release was over a year old
I’d considered MikroORM before, but the CommonJS/ESM struggles and lack of documentation held me back. Then MikroORM 7 dropped with native ESM support, zero core dependencies, and Kysely integration. The Reddit comments were encouraging:
“Big fan when it comes to the introduction of Kysely and ESM! A year ago I was still dreading even the idea of migrating our old Nest app away from using TypeOrm, but given the state of MikroORM and AI, I couldn’t be more excited to give it a shot in 2026”
So I started the migration. Here’s what worked and what didn’t.
Phase 1: The Audit (Don’t Skip This)
Before writing any code, I needed to understand what I was migrating. I ran:
find ./src -name "*.entity.ts" | wc -l# Output: 23
find ./src -name "*.migration.ts" | wc -l# Output: 6723 entities and 67 migration files. Not huge, but enough to require a strategy.
I documented three critical things:
- Custom repository methods - I had 12 custom methods extending
Repository - Subscribers and listeners - TypeORM’s event system for timestamps and soft deletes
- Raw queries - Several places I’d fallen back to
manager.query()for complex SQL
This audit revealed the actual scope: it wasn’t just entity conversion, but also the query patterns I’d built around TypeORM’s specific features.
Phase 2: Installation and Configuration
First, the easy part:
npm install @mikro-orm/core @mikro-orm/node @mikro-orm/postgresql
# For NestJS integrationnpm install @mikro-orm/nestjsThen the configuration. I created mikro-orm.config.ts:
import { defineConfig } from '@mikro-orm/postgresql';
export default defineConfig({ entities: ['./dist/entities'], entitiesTs: ['./src/entities'], dbName: process.env.DB_NAME!, host: process.env.DB_HOST!, port: parseInt(process.env.DB_PORT || '5432'), user: process.env.DB_USER!, password: process.env.DB_PASSWORD!, debug: process.env.NODE_ENV === 'development',});The entities vs entitiesTs split confused me at first. entities points to compiled JavaScript for production, while entitiesTs points to TypeScript source for development. Get this wrong and you’ll spend hours debugging “entity not found” errors.
Phase 3: Entity Conversion - The Meat of the Migration
This is where I made mistakes. Let me show you the wrong way first.
The Wrong Way: Direct Translation
I initially tried converting entities line by line:
// TypeORM original@Entity()export class User { @PrimaryGeneratedColumn() id: number;
@Column() name: string;
@OneToMany(() => Post, post => post.author) posts: Post[];}
// My first MikroORM attempt (WRONG)@Entity()export class User { @PrimaryKey() id!: number;
@Property() name!: string;
@OneToMany(() => Post, post => post.author) posts: Post[]; // This will break at runtime}The code compiled. Tests passed. Then I ran the application and got:
TypeError: Cannot read property 'add' of undefinedThe Right Way: Collection Wrapper
MikroORM requires Collection wrappers for OneToMany relationships:
import { Entity, PrimaryKey, Property, OneToMany, Collection } from '@mikro-orm/core';
@Entity()export class User { @PrimaryKey() id!: number;
@Property() name!: string;
@OneToMany(() => Post, post => post.author) posts = new Collection<Post>(this);
@Property({ onCreate: () => new Date() }) createdAt: Date = new Date();
@Property({ onUpdate: () => new Date() }) updatedAt: Date = new Date();}The Collection wrapper handles lazy loading, change tracking, and relationship management. Without it, you can’t add items to the relationship.
Handling Relationships
The relationship syntax is similar but has important differences:
ManyToOne:
// TypeORM@ManyToOne(() => User, user => user.posts)author: User;
// MikroORM - same syntax, different behavior@ManyToOne(() => User)author!: User;ManyToMany:
// TypeORM@ManyToMany(() => Post, post => post.tags)posts: Post[];
// MikroORM@ManyToMany(() => Post, post => post.tags)posts = new Collection<Post>(this);The pattern is consistent: if it’s a collection (OneToMany or ManyToMany), wrap it in Collection.
Custom Properties
TypeORM’s column options have MikroORM equivalents:
// TypeORM // MikroORM@Column({ unique: true }) @Property({ unique: true })@Column({ nullable: true }) @Property({ nullable: true })@Column({ default: 'active' }) @Property({ default: 'active' })@Column({ type: 'json' }) @Property({ type: 'json' })@CreateDateColumn() @Property({ onCreate: () => new Date() })@UpdateDateColumn() @Property({ onUpdate: () => new Date() })@DeleteDateColumn() @Property({ onDelete: () => new Date() })Phase 4: Query Migration
TypeORM and MikroORM have different query philosophies. TypeORM’s QueryBuilder is flexible but the types are loose. MikroORM’s approach is stricter but catches errors at compile time.
Simple Queries
// TypeORMconst users = await userRepository.find({ where: { department: 'Engineering' } });
// MikroORMconst users = await em.find(User, { department: 'Engineering' });The Entity Manager (em) is MikroORM’s central API. No repository needed for simple queries.
Complex Queries with Joins
// TypeORMconst users = await userRepository .createQueryBuilder('user') .leftJoinAndSelect('user.posts', 'post') .leftJoinAndSelect('post.comments', 'comment') .where('user.department = :dept', { dept: 'Engineering' }) .orderBy('user.name') .getMany();
// MikroORMconst users = await em.find(User, { department: 'Engineering' }, { populate: ['posts', 'posts.comments'], orderBy: { name: 'ASC' },});The populate array replaces join strings. It’s type-safe: TypeScript will error if you misspell a property.
Custom Repository Methods
TypeORM encourages custom repositories. MikroORM supports them, but the pattern differs:
// TypeORM@Injectable()export class UserRepository extends Repository<User> { findActiveWithPosts(): Promise<User[]> { return this.createQueryBuilder('user') .leftJoinAndSelect('user.posts', 'post') .where('user.status = :status', { status: 'active' }) .getMany(); }}
// MikroORM - extend EntityRepository@Injectable()export class UserRepository extends EntityRepository<User> { findActiveWithPosts(): Promise<User[]> { return this.find({ status: 'active' }, { populate: ['posts'] }); }}In NestJS, inject with @InjectRepository(User):
@Injectable()export class UsersService { constructor( @InjectRepository(User) private readonly userRepository: EntityRepository<User>, ) {}
async getActiveUsers(): Promise<User[]> { return this.userRepository.findActiveWithPosts(); }}When You Need Raw SQL
MikroORM 7 integrates Kysely for typed raw queries. For complex queries that don’t fit the ORM model:
// Get the Kysely instanceconst knex = em.getKnex();
// Raw SQL with type safetyconst result = await knex('users') .select(['id', 'name']) .where('created_at', '>', '2026-01-01') .join('posts', 'users.id', 'posts.author_id') .groupBy('users.id');This gives you the best of both worlds: ORM convenience for common operations, raw SQL power when needed.
Phase 5: Migration Strategy
I didn’t migrate everything at once. That’s a recipe for disaster. Instead:
- Pick one module - Start with the simplest entities
- Write the MikroORM version alongside the TypeORM code
- Add integration tests comparing both outputs
- Switch traffic gradually - Use feature flags if possible
- Remove TypeORM code after a week of production traffic
Schema Migration
MikroORM has its own schema tools:
# Preview changesnpx mikro-orm schema:update --dump
# Apply changesnpx mikro-orm schema:update --run
# Create a migrationnpx mikro-orm migration:createFor existing databases, I used schema:update --dump to preview what MikroORM would change. Most of the time, it matched my TypeORM schema exactly.
Common Mistakes I Made
1. Forgetting Collection Wrappers
This was the most common error. I’d convert an entity and forget the new Collection<T>(this):
// WRONG - Runtime error@OneToMany(() => Comment, comment => comment.post)comments: Comment[];
// CORRECT@OneToMany(() => Comment, comment => comment.post)comments = new Collection<Comment>(this);The error message was cryptic: “Cannot read property ‘add’ of undefined”. Always use Collection.
2. Not Flushing Changes
TypeORM auto-flushes on certain operations. MikroORM uses Unit of Work and requires explicit flush:
// TypeORM - auto-flushedawait repository.save(user);
// MikroORM - explicit flush neededem.persist(user);await em.flush();Forget the flush and your changes won’t persist.
3. Entity Registration Order
MikroORM needs to know about all entities. I initially forgot to register some:
// In NestJS moduleMikroOrmModule.forFeature([User, Post, Comment]) // Register ALL entitiesMissing entities cause “Entity not found” errors that are confusing to debug.
4. Schema Drift
After migration, I ran schema validation:
npx mikro-orm schema:update --dumpIt showed differences I hadn’t expected. Always validate after entity conversion.
Why MikroORM 7 Made This Possible
Earlier versions of MikroORM had pain points:
- CommonJS/ESM struggles - Module resolution errors were common
- Complex configuration - TypeScript compilation was tricky
- Limited query flexibility - Falling back to raw SQL was awkward
MikroORM 7 addressed all of these:
- Native ESM support - Works with tsx, swc, jiti, tsimp
- Zero core dependencies - Smaller bundle, fewer conflicts
- Kysely integration - Type-safe raw queries when the ORM isn’t enough
- defineEntity - Extend auto-generated classes with custom methods
The migration took about two weeks for 23 entities. That’s faster than I expected, and the result is a codebase with better type safety and more predictable behavior.
When to Stay with TypeORM
MikroORM isn’t always the right choice. Stay with TypeORM if:
- Your project is small and stable
- Your team is unfamiliar with MikroORM
- You rely heavily on TypeORM-specific features (subscribers, custom decorators)
- The migration cost outweighs the benefits
But if you’re experiencing TypeORM’s limitations—poor type safety, unpredictable query generation, or maintenance concerns—MikroORM 7 is worth the migration effort.
The Bottom Line
Migrating from TypeORM to MikroORM in 2026 is more practical than ever. MikroORM 7’s ESM support, Kysely integration, and improved TypeScript support removed the barriers that held me back before.
The key lessons from my migration:
- Audit first - Know what you’re migrating before you start
- Use Collection wrappers - Every OneToMany and ManyToMany relationship needs one
- Flush explicitly - MikroORM doesn’t auto-flush like TypeORM
- Migrate incrementally - One module at a time, with tests
- Validate schema - Use
schema:update --dumpto check for drift
The result? Better type safety, more predictable queries, and an actively maintained ORM. The two-week migration cost has already paid for itself in reduced debugging time.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 MikroORM Documentation
- 👨💻 MikroORM 7 Release Notes
- 👨💻 TypeORM to MikroORM Migration Guide
- 👨💻 Kysely Query Builder
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments