What is an N+1 Query?
You load a list of 50 authors from the database. Then for each author, your app fires a separate query to fetch their posts. That is 1 query for the authors + 50 queries for the posts = 51 queries total.
That is the N+1 problem. You wanted to fetch data once, but your ORM silently fires N extra queries behind the scenes.
The N+1 query problem happens when your code fetches a list of records (1 query), then lazily loads a relation for each record (N queries). It gets worse the more records you have.
Why Does It Happen in TypeORM?
TypeORM does not load relations automatically. When you access a relation like author.posts, it fires a separate query. Here is a typical setup that causes N+1:
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
import { Post } from "./post.entity";
@Entity()
export class Author {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column()
name: string;
@Column({ unique: true })
email: string;
@OneToMany(() => Post, (post) => post.author)
posts: Post[];
}import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
import { Author } from "./author.entity";
@Entity()
export class Post {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column()
title: string;
@Column("text")
content: string;
@Column({ default: 0 })
views: number;
@CreateDateColumn()
createdAt: Date;
@ManyToOne(() => Author, (author) => author.posts)
author: Author;
}The Problem Code
Here is a typical NestJS service that has the N+1 bug:
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Author } from "./entities/author.entity";
@Injectable()
export class AuthorsService {
constructor(
@InjectRepository(Author)
private readonly authorRepository: Repository<Author>,
) {}
async findAll(): Promise<Author[]> {
// This only fetches authors — posts are NOT loaded
const authors = await this.authorRepository.find();
// Now imagine the controller or serializer accesses author.posts
// TypeORM will fire a separate query for EACH author
return authors;
}
}When a controller returns these authors and something accesses author.posts, TypeORM fires one query per author. With 100 authors, that is 101 queries.
The SQL looks like this:
-- Query 1: fetch all authors
SELECT * FROM author;
-- Query 2: fetch posts for author #1
SELECT * FROM post WHERE "authorId" = 'uuid-1';
-- Query 3: fetch posts for author #2
SELECT * FROM post WHERE "authorId" = 'uuid-2';
-- ... repeats for every single author
-- Query 101: fetch posts for author #100
SELECT * FROM post WHERE "authorId" = 'uuid-100';How to Detect N+1 Queries
Before fixing the problem, you need to see it. Enable TypeORM query logging in your data source config:
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
export const dataSourceOptions: TypeOrmModuleOptions = {
type: "postgres",
host: process.env.DB_HOST ?? "localhost",
port: Number(process.env.DB_PORT) ?? 5432,
username: process.env.DB_USERNAME ?? "postgres",
password: process.env.DB_PASSWORD ?? "postgres",
database: process.env.DB_NAME ?? "app",
entities: [__dirname + "/**/*.entity{.ts,.js}"],
logging: ["query"], // Logs every SQL query to the console
};Now run your endpoint and count the queries in the terminal. If you see the same SELECT repeating with different IDs, you have an N+1.
Fix 1: Eager Loading with find Options
The simplest fix. Tell TypeORM to load the relation in the same request using a JOIN:
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Author } from "./entities/author.entity";
@Injectable()
export class AuthorsService {
constructor(
@InjectRepository(Author)
private readonly authorRepository: Repository<Author>,
) {}
async findAll(): Promise<Author[]> {
// relations: ["posts"] tells TypeORM to JOIN posts in one query
return this.authorRepository.find({
relations: ["posts"],
});
}
async findOne(id: string): Promise<Author | null> {
return this.authorRepository.findOne({
where: { id },
relations: ["posts"],
});
}
}TypeORM now generates a single query with a LEFT JOIN:
SELECT "author".*, "posts".*
FROM "author"
LEFT JOIN "post" "posts" ON "posts"."authorId" = "author"."id";One query instead of 101. This works well for simple cases, but it can get heavy when relations are deeply nested.
Fix 2: QueryBuilder for More Control
When you need to select specific columns, add conditions on the joined table, or paginate — use the QueryBuilder:
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Author } from "./entities/author.entity";
interface FindAuthorsOptions {
readonly page?: number;
readonly limit?: number;
readonly minPostViews?: number;
}
@Injectable()
export class AuthorsService {
constructor(
@InjectRepository(Author)
private readonly authorRepository: Repository<Author>,
) {}
async findAll(options: FindAuthorsOptions = {}): Promise<Author[]> {
const { page = 1, limit = 20, minPostViews } = options;
const qb = this.authorRepository
.createQueryBuilder("author")
.leftJoinAndSelect("author.posts", "post")
.orderBy("author.name", "ASC")
.skip((page - 1) * limit)
.take(limit);
// Conditionally filter posts with a minimum view count
if (minPostViews !== undefined) {
qb.andWhere("post.views >= :minViews", { minViews: minPostViews });
}
return qb.getMany();
}
}This gives you full control over what gets joined and filtered, while still avoiding N+1.
Fix 3: Separate Queries with In (Batch Loading)
Sometimes a JOIN is not the best choice — especially with large datasets where a join creates too many duplicate rows. Instead, fetch the relations in a second query using IN:
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { In, Repository } from "typeorm";
import { Author } from "./entities/author.entity";
import { Post } from "./entities/post.entity";
@Injectable()
export class AuthorsService {
constructor(
@InjectRepository(Author)
private readonly authorRepository: Repository<Author>,
@InjectRepository(Post)
private readonly postRepository: Repository<Post>,
) {}
async findAll(): Promise<Author[]> {
// Query 1: fetch all authors
const authors = await this.authorRepository.find();
// Query 2: fetch all posts for those authors in ONE query
const authorIds = authors.map((a) => a.id);
const posts = await this.postRepository.find({
where: { author: { id: In(authorIds) } },
});
// Map posts back to their authors
const postsByAuthorId = new Map<string, Post[]>();
for (const post of posts) {
const authorId = (post.author as unknown as { id: string }).id;
const existing = postsByAuthorId.get(authorId) ?? [];
existing.push(post);
postsByAuthorId.set(authorId, existing);
}
for (const author of authors) {
author.posts = postsByAuthorId.get(author.id) ?? [];
}
return authors;
}
}Two queries total — no matter how many authors. This avoids heavy JOINs and is often faster for large datasets.
-- Query 1: fetch all authors
SELECT * FROM "author";
-- Query 2: fetch all posts for those authors
SELECT * FROM "post" WHERE "authorId" IN ('uuid-1', 'uuid-2', ..., 'uuid-100');Fix 4: DataLoader Pattern for GraphQL and Nested Resolvers
If you use GraphQL with NestJS, the N+1 problem shows up in resolvers. The DataLoader pattern groups all loads within a single request into one query.
First, install the dependency:
npm install dataloaderThen create a loader:
import { Injectable, Scope } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import DataLoader from "dataloader";
import { In, Repository } from "typeorm";
import { Post } from "./entities/post.entity";
@Injectable({ scope: Scope.REQUEST })
export class PostsLoader {
private readonly loader: DataLoader<string, Post[]>;
constructor(
@InjectRepository(Post)
private readonly postRepository: Repository<Post>,
) {
this.loader = new DataLoader<string, Post[]>(async (authorIds: readonly string[]) => {
// One query for all requested author IDs
const posts = await this.postRepository.find({
where: { author: { id: In([...authorIds]) } },
});
// Group posts by author ID
const postsByAuthorId = new Map<string, Post[]>();
for (const post of posts) {
const authorId = (post.author as unknown as { id: string }).id;
const existing = postsByAuthorId.get(authorId) ?? [];
existing.push(post);
postsByAuthorId.set(authorId, existing);
}
// Return in the same order as the input IDs
return authorIds.map((id) => postsByAuthorId.get(id) ?? []);
});
}
async loadByAuthorId(authorId: string): Promise<Post[]> {
return this.loader.load(authorId);
}
}Use it in a resolver:
import { Parent, Query, ResolveField, Resolver } from "@nestjs/graphql";
import { Author } from "./entities/author.entity";
import { Post } from "./entities/post.entity";
import { AuthorsService } from "./authors.service";
import { PostsLoader } from "./posts.loader";
@Resolver(() => Author)
export class AuthorsResolver {
constructor(
private readonly authorsService: AuthorsService,
private readonly postsLoader: PostsLoader,
) {}
@Query(() => [Author])
async authors(): Promise<Author[]> {
return this.authorsService.findAll();
}
@ResolveField(() => [Post])
async posts(@Parent() author: Author): Promise<Post[]> {
// DataLoader batches all calls within the same request into one query
return this.postsLoader.loadByAuthorId(author.id);
}
}Even if 50 authors are returned and each triggers posts(), the DataLoader collapses all 50 into one batched query.
Fix 5: Entity-Level eager: true
For relations that you always need, you can set eager: true on the entity itself:
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
import { Post } from "./post.entity";
@Entity()
export class Author {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column()
name: string;
@Column({ unique: true })
email: string;
@OneToMany(() => Post, (post) => post.author, { eager: true })
posts: Post[];
}Now every .find() and .findOne() call automatically joins posts — no need to specify relations.
Warning: Use
eager: truesparingly. It loads the relation on every query, even when you do not need it. This can turn an N+1 problem into an over-fetching problem. Only use it for relations that are truly always needed.
Comparison
| Fix | Queries | Best For | Trade-off |
| ---------------------- | -------- | -------------------------------------- | ---------------------------------------------- |
| relations option | 1 (JOIN) | Simple cases, shallow relations | Heavy JOINs on large datasets |
| QueryBuilder | 1 (JOIN) | Filtering, pagination, partial selects | More verbose code |
| Batch with In | 2 | Large datasets, avoiding heavy JOINs | Manual mapping required |
| DataLoader | 2 | GraphQL, nested resolvers | Extra dependency, request-scoped |
| eager: true | 1 (JOIN) | Relations always needed | Over-fetches when you do not need the relation |
Best Practices
1. Never rely on lazy loading in production. TypeORM lazy relations (Promise<T>) are convenient, but they hide N+1 problems. Always load what you need explicitly.
2. Use DTOs to control what gets returned. Do not return raw entities from your controllers. Map to a DTO so you know exactly what data is needed — and only load that.
export class AuthorResponseDto {
readonly id: string;
readonly name: string;
readonly email: string;
readonly posts: PostSummaryDto[];
constructor(author: {
id: string;
name: string;
email: string;
posts: { id: string; title: string }[];
}) {
this.id = author.id;
this.name = author.name;
this.email = author.email;
this.posts = author.posts.map((p) => new PostSummaryDto(p));
}
}
export class PostSummaryDto {
readonly id: string;
readonly title: string;
constructor(post: { id: string; title: string }) {
this.id = post.id;
this.title = post.title;
}
}3. Enable query logging in development. Always know how many queries your endpoints fire. If you see the same SELECT repeating with different parameters, you have an N+1.
4. Profile with EXPLAIN ANALYZE. After fixing N+1, make sure your JOINs are efficient. Add indexes on foreign keys:
import {
Column,
CreateDateColumn,
Entity,
Index,
ManyToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { Author } from "./author.entity";
@Entity()
export class Post {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column()
title: string;
@Column("text")
content: string;
@Column({ default: 0 })
views: number;
@CreateDateColumn()
createdAt: Date;
@Index() // Index on the foreign key for faster JOINs
@ManyToOne(() => Author, (author) => author.posts)
author: Author;
}5. Pick the right fix for the context. A simple relations: ["posts"] is enough for most REST endpoints. Use QueryBuilder when you need filtering or pagination. Use DataLoader for GraphQL. Use batch loading with In for large datasets.
Summary
The N+1 query problem is one of the most common performance issues in ORM-based applications. It is easy to miss in development because everything still works — just slowly.
The fix depends on your use case:
- Simple REST endpoint →
relationsoption oreager: true - Need filtering or pagination → QueryBuilder
- Large datasets → Batch with
In - GraphQL → DataLoader
- Always needed relation →
eager: true(with caution)
Your ORM should work for you, not against you. Know what queries it generates, and you will never be surprised in production.