EnglishPublished Apr 26, 2026Updated Apr 26, 20265 min read

Mastering TypeORM Query Execution: A Guide to Patterns, Performance & Transactions

Tips

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

ts
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() and delete() skip entity lifecycle hooks (@BeforeUpdate, etc.)
  • update() returns UpdateResult, 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

ts
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 leftJoin vs leftJoinAndSelect
  • 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

ts
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

ts
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

ts
// 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

ts
// 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, not dataSource.query().

❌ The Anti-Pattern (Silent Data Corruption)

ts
@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

ts
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

MethodTransaction-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

ts
// ✅ 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

ts
// ❌ 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

ts
// ❌ 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

ts
// 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

ts
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?

Diagram
Rendering diagramâ€Ļ

🔚 Conclusion: Principles Over Patterns

After years of debugging race conditions and performance bottlenecks, here are my non-negotiables:

  1. Transactions first: If data consistency matters, wrap related operations in dataSource.transaction() and always use the transactional EntityManager.
  2. Prefer explicit over clever: findOneBy({ id }) is clearer than findOne({ where: { id } }). Future-you will thank present-you.
  3. Profile before optimizing: Don't reach for raw SQL because you assume TypeORM is slow. Use logging: true or a query profiler first.
  4. Encapsulate complexity: If a query appears in 3+ places, make it a custom repository method.
  5. 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