IQueryable vs IEnumerable: the one return type that decides where your query runs
If I could make every .NET developer internalize one distinction in EF Core, it wouldn't be
change tracking or migrations or any of the usual interview material. It would be this:
IQueryable<T> and IEnumerable<T> look almost identical and decide completely different things.
One builds a query the database runs. The other iterates results in your process. The line between
them is the line between "WHERE on an indexed column" and "load the whole table, then filter it in
C#." Most people cross that line by accident, in a method signature, and never find out.
They share an interface, not a brain
IQueryable<T> extends IEnumerable<T>, which is why they're so easy to confuse. The methods
you call look the same: Where, Select, OrderBy, Take. The difference is what those
methods are made of.
When you compose Where on an IQueryable, you're handing EF Core an expression tree, a data
structure describing your intent that the provider can translate to SQL. Nothing has run yet. The
query executes when you enumerate it (ToListAsync, FirstAsync, a foreach), and at that
moment EF Core turns the whole composed tree into one SQL statement.
When you compose Where on an IEnumerable, you're using LINQ to Objects. The predicate is a
compiled delegate that runs in your process, item by item, over data that is already in
memory. There's no SQL involved because there's nothing left to translate. The rows are already
here. You're just looping over them.
That's the whole trap: the moment a query stops being IQueryable and becomes IEnumerable,
every operation after that point happens in C#, on whatever rows you've already pulled.
The signature that quietly loads a million rows
Here's a repository method. It looks clean. It compiles. It passes its tests.
public IEnumerable<User> GetActiveUsers()
{
return _db.Users.Where(u => u.IsActive);
}
The bug is the return type. _db.Users.Where(...) is an IQueryable<User>, but the method
declares it returns IEnumerable<User>, so it gets upcast. The query is still deferred at this
exact line, which is what makes it sneaky. The damage happens at the call site:
var recent = userService.GetActiveUsers()
.OrderByDescending(u => u.LastLoginAt)
.Take(20)
.ToList();
Read that carefully. GetActiveUsers() returns IEnumerable<User>, so OrderByDescending and
Take are LINQ to Objects. They can't be part of the SQL, because the type system already decided
this is in-memory data. What EF Core actually sends is:
SELECT u.id, u.email, u.is_active, u.last_login_at, /* every column */
FROM users AS u
WHERE u.is_active = true;
Every active user. All of them. If that's 1,000,000 rows, EF Core materializes 1,000,000 User
objects into your process, then C# sorts a million-element list and takes the first twenty. The
ordering and the paging, the parts that should have ridden an index and returned twenty rows,
happened after the table was already in your heap.
Here is what each return type lets across the boundary:
IQueryable<User> return
database runs
WHERE + ORDER BY + LIMIT 20
one composed SQL statement
20 rows
materialized in memory
IEnumerable<User> return
database runs
WHERE is_active only
ORDER BY and LIMIT never arrive
1,000,000 rows
sorted and paged in C#
999,980 discarded
The one-character difference
Change the return type to IQueryable<User>:
public IQueryable<User> GetActiveUsers()
{
return _db.Users.Where(u => u.IsActive);
}
Now the call site composes onto a live expression tree. OrderByDescending, Take, and the
final ToList are all part of the same deferred query, and EF Core translates the lot into one
statement:
SELECT u.id, u.email, u.is_active, u.last_login_at
FROM users AS u
WHERE u.is_active = true
ORDER BY u.last_login_at DESC
LIMIT 20;
With an index on (is_active, last_login_at DESC), the database walks twenty rows off the front of
the index and stops. Twenty User objects cross the wire. 1,000,000 rows materialized to 20.
The C# at the call site didn't change one character. The return type did.
IEnumerable<User> return |
IQueryable<User> return |
|
|---|---|---|
| Where runs | database | database |
| OrderBy / Take run | in C# memory | database |
| Rows pulled | every active user | 20 |
| Index used for paging | no | yes |
ToList, AsEnumerable, and the early exit
The return type is the most common way to fall off the cliff, but it isn't the only one. Any call that forces enumeration ends the SQL-buildable phase right there:
var users = _db.Users.ToList(); // executes now: full table to memory
var filtered = users.Where(u => u.IsActive); // LINQ to Objects, in C#
ToList(), ToArray(), AsEnumerable(), and a foreach all draw the line. Everything before is
SQL; everything after is your process. The rule worth carrying: stay IQueryable as long as you
can, and only enumerate once you have narrowed the result to what you actually need. Filter,
order, and page in the query. Materialize last.
The opposite mistake, calling AsEnumerable() to dodge a translation error, is sometimes
legitimate (you genuinely need a C# method EF Core can't translate). But do it after the WHERE
and the paging, not before, so the database still cuts the data down first. Position matters more
than the call itself.
Why your tests will never warn you
This is the part that makes it dangerous in real codebases. The IEnumerable version and the
IQueryable version return the exact same twenty users in the exact same order. Every
assertion passes. The output is byte-for-byte identical. The only difference is that one version
asked the database for twenty rows and the other asked for a million and threw away 999,980 of
them, and nothing in the test output distinguishes those two worlds.
A correctness test can't see the difference between a query that pages in the database and one that pages in memory. They produce the same list. The cost lives entirely in the SQL, which the test never looks at.
So you catch it exactly one way: by looking at the generated SQL and noticing there's no LIMIT
and no ORDER BY in it. That's a skill, and like every skill it comes from reps where you can
actually see the consequence. Katabench's database puzzles run your LINQ against a real PostgreSQL
database and show you the SQL it produced, so the moment a stray IEnumerable turns your indexed
WHERE into a full scan, it's right there in the query log instead of hiding behind a passing
assertion. You can read how the tracks are structured if you want the full picture.
The fix in your code is one type change. The hard part was ever seeing that you needed it, and that's the part worth practicing until it's automatic. The Pro plan opens the database track if you want to make "where does this query actually run?" a question you ask without thinking.