Mastering TypeORM Query Execution: A Guide to Patterns, Performance & Transactions
TypeORM Version: 0.3.x+
Focus: Production-ready patterns, transaction safety, and performance trade-offs
đ¯ Introduction
TypeORM is a powerful ORM for TypeScript/JavaScript, but its flexibility can become a liability if you don't understand the trade-offs between its query execution strategies. After shipping multiple high-traffic Node.js services, I've learned that choosing the right query method isn't about preferenceâit's about correctness, maintainability, and performance.
This article breaks down every way to run a query in TypeORM, when to use each, andâmost criticallyâhow to make them work reliably inside transactions. No fluff, just battle-tested insights.
đ The 6 Query Execution Strategies (TypeORM 0.3+)
1. Repository API â The 80% Solution
const posts = await postRepository.find({
where: {
published: true,
createdAt: MoreThan(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000))
},
relations: ['author', 'tags'],
order: { createdAt: 'DESC' },
take: 20
});
â Use when:
- Simple CRUD with filters, pagination, or relations
- You want type safety with minimal boilerplate
- Read-heavy operations with predictable shapes
â ī¸ Caveats:
update()anddelete()skip entity lifecycle hooks (@BeforeUpdate, etc.)update()returnsUpdateResult, not the updated entity- Complex joins become verbose quickly
Senior Tip: Prefer findOneBy({ id }) over findOne({ where: { id } }) for exact-match lookupsâit's marginally faster and more explicit in TypeORM 0.3+.
2. QueryBuilder â When Complexity Demands Control
const analytics = await postRepository
.createQueryBuilder('post')
.select([
'post.id',
'post.title',
'COUNT(comment.id) as comment_count',
'AVG(rating.value) as avg_rating'
])
.leftJoin('post.comments', 'comment')
.leftJoin('post.ratings', 'rating')
.where('post.published = :published', { published: true })
.andWhere('post.createdAt BETWEEN :start AND :end', {
start: startDate,
end: endDate
})
.groupBy('post.id')
.having('COUNT(comment.id) > :minComments', { minComments: 5 })
.orderBy('avg_rating', 'DESC')
.getRawMany();
â Use when:
- Aggregations, subqueries, or window functions
- Dynamic query construction (e.g., admin filters)
- You need fine-grained control over SELECT fields for performance
â ī¸ Caveats:
- Steeper learning curve; easy to introduce N+1 if you misuse
leftJoinvsleftJoinAndSelect - Raw results (
.getRawMany()) bypass entity mappingâno methods, no getters
Senior Tip: Always parameterize values (:param)ânever interpolate user input directly. TypeORM won't save you from SQL injection if you build strings manually.
3. EntityManager â The Transaction Workhorse
const manager = dataSource.manager;
// Direct usage
const user = await manager.findOneBy(User, { email });
// Or via repository
const postRepo = manager.getRepository(Post);
await postRepo.save({ title: "New", authorId: user.id });
â Use when:
- You're inside a service layer and don't want to inject 10 repositories
- You need to coordinate operations across multiple entities
- Most importantly: You're writing transactional code (more below)
Senior Tip: EntityManager is your gateway to transaction safety. If you're not using it inside dataSource.transaction(), you're probably doing transactions wrong.
4. Raw SQL â The Escape Hatch
const [results] = await postRepository.query(
`
SELECT
p.id,
p.title,
ts_rank(p.search_vector, to_tsquery('english', $1)) as rank
FROM posts p
WHERE p.published = true
AND p.search_vector @@ to_tsquery('english', $1)
ORDER BY rank DESC
LIMIT $2
`,
['typeorm & performance', 10]
);
â Use when:
- You need database-specific features (PostgreSQL full-text search, CTEs, JSONB operators)
- TypeORM's query builder generates inefficient SQL for your use case
- You're doing analytics or reporting where entity mapping is overhead
â ī¸ Caveats:
- Zero type safety; column names are strings
- Results aren't mapped to entitiesâno methods, no relations
- Harder to test and refactor
Senior Tip: Wrap raw queries in a repository method with a clear interface. Document the expected result shape with a TypeScript interface to regain some type safety.
5. DataSource API â For Scripts and Bootstrapping
// Quick script
const dbVersion = await dataSource.query('SELECT version()');
// Or get a repo ad-hoc
const count = await dataSource
.getRepository(User)
.count({ where: { status: 'active' } });
â Use when:
- CLI tools, migration scripts, or one-off diagnostics
- You're outside the DI container (e.g.,
main.ts)
â ī¸ Caveats: Avoid in application servicesâit couples you to the global DataSource and makes testing harder.
6. Custom Repositories â Encapsulate Domain Logic
// post.repository.ts
@EntityRepository(Post)
export class PostRepository extends Repository<Post> {
async findTrending(tag?: string, limit = 10) {
const qb = this.createQueryBuilder('post')
.leftJoinAndSelect('post.author', 'author')
.where('post.published = :published', { published: true })
.orderBy('post.views', 'DESC')
.take(limit);
if (tag) {
qb.innerJoin('post.tags', 'tag')
.andWhere('tag.name = :tag', { tag });
}
return qb.getMany();
}
async bulkPublish(ids: number[]) {
return this.createQueryBuilder()
.update(Post)
.set({ published: true, publishedAt: () => 'NOW()' })
.where('id IN (:...ids)', { ids })
.execute();
}
}
â Use when:
- You have complex, reusable query logic tied to an entity
- You want to keep controllers/services thin and testable
- You need to share query patterns across multiple services
Senior Tip: Always use this.manager inside custom repository methodsânot this directly for queriesâto ensure transaction compatibility.
đ Transactions: The Make-or-Break Detail
The Golden Rule
All queries inside a transaction must execute through the transactional
EntityManagerânot your injected repositories, notdataSource.query().
â The Anti-Pattern (Silent Data Corruption)
@Injectable()
class OrderService {
constructor(
@InjectRepository(Order) private orderRepo: Repository<Order>,
@InjectRepository(Inventory) private inventoryRepo: Repository<Inventory>,
private dataSource: DataSource
) {}
async placeOrder(orderDto: CreateOrderDto) {
// â ī¸ DANGER: These repos use the DEFAULT EntityManager
await this.dataSource.transaction(async () => {
const order = await this.orderRepo.save(orderDto); // â Outside transaction!
// This update also runs outside the transaction
await this.inventoryRepo.decrement(order.items); // â Race condition possible
});
}
}
Why this fails: Injected repositories are bound to the default EntityManager. When you call dataSource.transaction(), it creates a new EntityManager with a dedicated DB connection. Your injected repos don't know about it.
â The Correct Pattern
async placeOrder(orderDto: CreateOrderDto) {
return await this.dataSource.transaction(async (manager) => {
// â
Get repositories FROM the transactional manager
const orderRepo = manager.getRepository(Order);
const inventoryRepo = manager.getRepository(Inventory);
const order = await orderRepo.save(orderDto); // â
Inside transaction
await inventoryRepo.decrement(order.items); // â
Same transaction
return order;
});
}
Transaction Compatibility Matrix
| Method | Transaction-Safe? | Requirement |
|---|---|---|
| Repository API | â | Must come from manager.getRepository() |
| QueryBuilder | â | Created from transactional manager/repo |
| EntityManager | â â | Use the manager from callback |
Raw SQL (query()) | â | Call manager.query(), not dataSource.query() |
| DataSource API | â | Avoid inside transactions |
| Custom Repository | â | Must use this.manager, not hardcoded DataSource |
đ Performance Considerations
1. update() vs save() for Partial Updates
// â
Fast: Single UPDATE query, no SELECT
await postRepository.update(
{ id: postId },
{ views: () => 'views + 1' }
);
// â Slower: SELECT + UPDATE + lifecycle hooks
const post = await postRepository.findOneBy({ id: postId });
post.views += 1;
await postRepository.save(post);
Rule of thumb: Use update() for blind updates. Use save() when you need the entity in memory or rely on lifecycle hooks.
2. Avoid N+1 with Relations
// â N+1: One query for posts, then one per post for comments
const posts = await postRepository.find({ where: { published: true } });
for (const post of posts) {
post.comments = await commentRepository.findBy({ postId: post.id });
}
// â
Single query with JOIN
const posts = await postRepository.find({
where: { published: true },
relations: ['comments']
});
Senior Tip: For large datasets, consider splitting into two queries with IN clauses instead of massive JOINsâsometimes it's faster and easier to cache.
3. Pagination: skip/take vs Cursor-Based
// â Offset pagination gets slower as offset grows
const page2 = await repo.find({ skip: 10000, take: 20 });
// â
Cursor pagination: consistent performance
const posts = await repo
.createQueryBuilder('post')
.where('post.published = :published', { published: true })
.andWhere('post.createdAt < :cursor', { cursor: lastSeenDate })
.orderBy('post.createdAt', 'DESC')
.take(20)
.getMany();
đ§Ē Testing Strategies
Mocking Repositories
// test-utils.ts
export const createMockRepository = <T>(methods: Partial<Repository<T>>) => {
return {
createQueryBuilder: jest.fn(() => ({
select: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
getMany: jest.fn(),
// ... chainable methods
})),
...methods,
} as unknown as Repository<T>;
};
// In your test
const mockPostRepo = createMockRepository({
findOneBy: jest.fn().mockResolvedValue(mockPost),
});
Testing Transactions
it('should rollback on error', async () => {
await expect(
dataSource.transaction(async (manager) => {
const repo = manager.getRepository(User);
await repo.save({ email: 'test@example.com' });
throw new Error('Simulated failure');
})
).rejects.toThrow('Simulated failure');
// Verify no data was persisted
const count = await dataSource.getRepository(User).count();
expect(count).toBe(0);
});
đ§ Decision Framework: Which Method Should I Use?
đ Conclusion: Principles Over Patterns
After years of debugging race conditions and performance bottlenecks, here are my non-negotiables:
- Transactions first: If data consistency matters, wrap related operations in
dataSource.transaction()and always use the transactionalEntityManager. - Prefer explicit over clever:
findOneBy({ id })is clearer thanfindOne({ where: { id } }). Future-you will thank present-you. - Profile before optimizing: Don't reach for raw SQL because you assume TypeORM is slow. Use
logging: trueor a query profiler first. - Encapsulate complexity: If a query appears in 3+ places, make it a custom repository method.
- Test transactions: Write at least one test that verifies rollback behavior. It's the easiest way to catch transaction leaks.
TypeORM gives you many ways to shoot yourself in the foot. But with disciplined patterns and a clear mental model of how EntityManager and transactions interact, you can build robust, maintainable data layers that scale.
đ Further Reading: TypeORM Docs, PostgreSQL EXPLAIN ANALYZE Guide
Last updated: April 2026 | TypeORM 0.3.20