Sequelize Eager Loading: Fixing N+1 Queries Without Creating New Ones
Sequelize's include option fixes N+1 queries, but required, where, and separate each change the generated SQL in ways that cause subtle bugs. Here's how to use them correctly — and when lazy loading wins.
25 Apr 2026, 12:58 UTC

You fetch 50 users, then loop over them and call user.getPosts() for each one. Your endpoint now issues 51 queries. This is the classic N+1 problem, and Sequelize's answer is eager loading with include. The catch is that include has three options — required, where, and separate — that silently change what your query returns, and getting them wrong produces bugs that look like missing data.
The thesis: eager loading is the right default for associations you always need, but you have to understand what each include option does to the generated SQL, or you'll trade an N+1 for a cartesian blowup or a filtering bug.
What include actually does
By default, User.findAll({ include: Post }) generates a LEFT OUTER JOIN. Every user comes back, and each user's Posts array is populated from the joined rows. One round trip, no N+1.
That default matters because of what happens when you add a filter. This is the single most common eager-loading bug:
// Looks like "users who have a published post" — it is not.
const users = await User.findAll({
include: [{
model: Post,
where: { published: true },
}],
});Because the join is still a LEFT OUTER JOIN, this returns all users. Users without a published post just get an empty (or partially matched) Posts array. The where filters the joined rows, not the parents. If you want to filter parents, you need required: true, which switches the join to an INNER JOIN:
const users = await User.findAll({
include: [{
model: Post,
where: { published: true },
required: true, // INNER JOIN: users without a published post are excluded
}],
});The flip side: if you add required: true just to "make the query faster," you've also changed result cardinality — parents without children vanish. Check your join type in the generated SQL before assuming a row count is correct.
When the JOIN itself becomes the problem
A JOIN include works well for belongsTo and hasOne (one child row per parent). For hasMany with large child sets, it backfires. If each user has 500 posts and you join 50 users, the database returns 25,000 rows that Sequelize then has to deduplicate and reassemble in JavaScript. Memory and transfer cost can exceed the N+1 you were avoiding.
separate: true is the escape hatch. Instead of one giant join, Sequelize runs the parent query first, then one dedicated query per association using an IN clause on the parent IDs:
const users = await User.findAll({
limit: 50,
include: [{
model: Post,
separate: true, // own query, no row explosion
attributes: ['id', 'title'], // don't haul columns you won't render
}],
attributes: ['id', 'name'],
});Note the attributes on both levels. Over-eager loading of wide graphs — every column of every related table — is the other way "one query" becomes slower than several small ones.
Two caveats on separate: it only applies to hasMany/belongsToMany (there's nothing to separate in a one-to-one join), and it doesn't guarantee "one query" either — nested includes can still issue several statements. Also watch as aliases: if your association is defined with as: 'articles', the include must use { model: Post, as: 'articles' }, or Sequelize throws an "unexpected error" about the association not existing.
Pagination with includes: the subQuery trap
Combining limit/offset with a hasMany include is genuinely tricky. If the limit were applied to the joined rows, a user with 10 posts could consume your whole page. Sequelize handles this with a subquery: it first selects the limited set of parent IDs, then joins. In v6 this is the default when a limit is combined with includes (subQuery: true), but the behavior has shifted across versions, so verify rather than assume.
Practical check: create a fixture where one parent has many children, paginate with limit: 5, and assert you get exactly 5 parents back. If you get fewer, your pagination is being applied to joined rows.
When lazy loading is the better choice
Eager loading always pays the join (or extra query) cost, whether or not the data gets used. If an association is only needed conditionally — say, posts only load when a request passes ?expand=posts — lazy loading via the generated mixin methods is cheaper:
const user = await User.findByPk(id);
if (expandPosts) {
user.Posts = await user.getPosts({ where: { published: true } });
}Lazy loading is also fine inside a loop over a small, bounded set (three admins, not three thousand users). The N+1 problem is about scale, not about the pattern itself.
Verify it, don't trust it
Sequelize's query generation has enough version-dependent defaults that reading the SQL beats reading the docs. Enable logging on the constructor (new Sequelize(url, { logging: console.log })) or per-query, and check three things:
- Statement count: fetch with and without
includeand confirm the per-parent queries disappeared. - Join type: toggle
required: trueand confirm LEFT becomes INNER — and that the row count change is what you intended. - Row width: if the joined result is huge, try
separate: trueand benchmark both on realistic data. The right answer depends on your child-rows-per-parent distribution, not on a rule of thumb.
The actionable version: default to include for associations you always render, set required: true only when you mean "filter parents," reach for separate: true when child sets are large, trim attributes everywhere, and keep lazy loading for conditional paths. Then count the actual queries before you ship it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.