Katabench
Start free
← All posts

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 database can only optimize the question it is actually asked. Ask in IQueryable and 20 rows cross the boundary; ask in IEnumerable and a million do, so your heap can discard 999,980.

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.

Get new puzzles and .NET tips in your inbox

A short note when fresh kata land, plus the C# and performance tricks behind the grading. No spam, unsubscribe anytime.