.NET y backend

IQueryable vs IEnumerable: dónde se ejecuta tu consulta

El mismo Where puede filtrar en SQL Server o en la memoria de tu API. Cómo distinguir IQueryable de IEnumerable en EF Core y dónde cortar la consulta.

Por Leyton Manuel Espitia Diaz 7 min de lectura

Fragmento de código C# que compara un filtro sobre IEnumerable con uno sobre IQueryable en EF Core

El endpoint devuelve los pedidos pendientes de un cliente. En desarrollo responde al instante. En producción, con la tabla ya crecida, tarda segundos y el consumo de memoria de la API sube con cada petición. El código no tiene nada raro: un Where, un OrderBy y un Take(20).

El problema no está en lo que hace la consulta, sino en dónde lo hace. Un método intermedio devolvía IEnumerable<Order>, y desde ese punto el filtro dejó de ejecutarse en SQL Server y pasó a ejecutarse en la memoria de la aplicación, sobre la tabla completa.

La diferencia que importa

Las dos interfaces exponen casi los mismos métodos de LINQ, y por eso es tan fácil confundirlas. Lo que cambia es qué reciben esos métodos:

  • IEnumerable<T> recibe delegados (Func<T, bool>): código compilado que se ejecuta elemento por elemento, en memoria.
  • IQueryable<T> recibe árboles de expresión (Expression<Func<T, bool>>): una descripción de la consulta que el proveedor, en este caso EF Core, traduce a SQL.

Mientras la consulta sea IQueryable<T>, cada operador que agregas modifica el SQL final. En cuanto se convierte en IEnumerable<T>, lo que venga después se resuelve en tu proceso.

El mismo código, dos consultas distintas

// ❌ El filtro corre en memoria: se trae toda la tabla
IEnumerable<Order> orders = db.Orders;
var pending = orders
    .Where(o => o.CustomerId == customerId && o.Status == OrderStatus.Pending)
    .OrderByDescending(o => o.CreatedAt)
    .Take(20)
    .ToList();
// ✅ El filtro viaja a SQL Server
IQueryable<Order> orders = db.Orders;
var pending = await orders
    .Where(o => o.CustomerId == customerId && o.Status == OrderStatus.Pending)
    .OrderByDescending(o => o.CreatedAt)
    .Take(20)
    .ToListAsync(ct);

Lo único que cambió fue el tipo de la variable. En el primer caso EF Core ejecuta un SELECT de todas las columnas y todas las filas, materializa cada pedido como entidad y después descarta casi todos. En el segundo, SQL Server recibe el WHERE, el ORDER BY y el TOP (20), y puede usar un índice.

La trampa es que el compilador no avisa. db.Orders es un DbSet<Order>, que implementa las dos interfaces, y C# elige la sobrecarga de Where según el tipo estático de la variable.

Dónde se pierde el IQueryable sin darse cuenta

En código real casi nadie escribe IEnumerable<Order> orders = db.Orders. La conversión ocurre de formas menos visibles:

  • Un método que devuelve IEnumerable<T>. Es el caso más común: un repositorio o servicio con IEnumerable<Order> GetOrders(). Quien lo llama agrega su Where creyendo que filtra en la base de datos.
  • AsEnumerable() o ToList() a mitad de la consulta, normalmente para esquivar un error de traducción. Lo que sigue se ejecuta en memoria.
  • Un parámetro Func<T, bool> en lugar de Expression<Func<T, bool>>. Un método como Find(Func<Order, bool> filter) obliga a usar la sobrecarga de IEnumerable.
  • Un foreach sobre el DbSet con un if adentro: es un filtro en memoria escrito a mano.

El tercer caso merece un ejemplo, porque se ve inofensivo:

// ❌ Func: el predicado no se puede traducir a SQL
public List<Order> Find(Func<Order, bool> filter) =>
    db.Orders.Where(filter).ToList();

// ✅ Expression: EF Core lo convierte en un WHERE
public Task<List<Order>> FindAsync(Expression<Func<Order, bool>> filter, CancellationToken ct) =>
    db.Orders.Where(filter).ToListAsync(ct);

Cómo comprobar dónde se ejecuta

No hace falta adivinar. ToQueryString() devuelve el SQL que EF Core va a generar para un IQueryable:

var query = db.Orders
    .Where(o => o.CustomerId == customerId)
    .OrderByDescending(o => o.CreatedAt)
    .Take(20);

logger.LogDebug("{Sql}", query.ToQueryString());

Si el WHERE que esperabas no aparece en ese SQL, el filtro se está aplicando en otro lado. El registro de comandos de EF Core (Microsoft.EntityFrameworkCore.Database.Command en nivel Information) muestra lo mismo para todas las consultas; es una de las primeras cosas que conviene revisar ante problemas de rendimiento con EF Core en producción.

Hay una protección adicional: desde EF Core 3.0, una expresión que no se puede traducir lanza una excepción en lugar de evaluarse en el cliente en silencio. Esa excepción es una buena noticia. El reflejo de "arreglarla" con un ToList() antes del filtro es justo lo que reintroduce el problema.

Cuándo sí conviene pasar a memoria

Evaluar en el cliente no es un error en sí. Hay lógica que SQL no puede ejecutar: un método propio, un formateo con cultura, una regla que vive en C#. La forma correcta es reducir primero en la base de datos y cambiar de lado de manera explícita:

var rows = await db.Orders
    .Where(o => o.CustomerId == customerId)       // SQL
    .Select(o => new { o.Id, o.Total, o.CreatedAt }) // SQL: solo tres columnas
    .ToListAsync(ct);

var result = rows
    .Select(o => new OrderSummary(o.Id, FormatTotal(o.Total), o.CreatedAt)) // memoria
    .ToList();

El ToListAsync marca la frontera a propósito: quien lee el código ve dónde termina SQL y dónde empieza C#. Las demás técnicas para que la consulta generada sea eficiente están en cómo optimizar consultas SQL desde EF Core.

El trade-off de exponer IQueryable

Si IEnumerable es peligroso, la conclusión rápida es devolver IQueryable<T> desde todos lados. Tampoco es gratis:

  • La consulta queda abierta. Cualquier capa puede agregar filtros, Include u ordenamientos. El SQL final ya no lo decide quien escribió el método, y dos llamadas al mismo método pueden generar consultas muy distintas.
  • Ejecución diferida. La consulta corre cuando alguien la enumera, que puede ser lejos de donde se creó. Si para entonces el DbContext ya se liberó, el error aparece en un lugar que no tiene nada que ver con la causa.
  • Enumeración múltiple. Recorrer dos veces el mismo IQueryable ejecuta la consulta dos veces.

Por eso exponer IQueryable<T> desde un repositorio suele indicar que la abstracción ya no oculta nada, algo que discutimos en si el Repository Pattern sigue teniendo sentido con EF Core.

Una regla práctica que funciona en la mayoría de proyectos: IQueryable<T> dentro del componente que construye la consulta; datos ya materializados hacia afuera. Los métodos privados y los métodos de extensión que componen filtros reciben y devuelven IQueryable<T>. Lo que cruza hacia un controlador, otro módulo o una vista es un List<T> o un IReadOnlyList<T> de DTOs.

Cuándo no vale la pena preocuparse

  • Tablas pequeñas y estables (países, tipos de documento, estados): traerlas completas y filtrar en memoria es razonable, y a menudo más simple si además se guardan en caché.
  • Colecciones que ya están en memoria. Convertir una lista a IQueryable con AsQueryable() no la hace más rápida; solo agrega el costo de interpretar árboles de expresión.
  • Código que no toca la base de datos. Para lógica de dominio sobre objetos, IEnumerable<T> es la interfaz correcta.

Conclusión

La pregunta útil no es "¿cuál interfaz es mejor?", sino "¿en qué línea deja esta consulta de ser SQL?". Mientras sea IQueryable<T>, la base de datos hace el trabajo; desde el primer IEnumerable<T>, lo hace tu API. Vale la pena que ese cambio sea visible y deliberado, con un ToListAsync en el lugar elegido, y no la consecuencia accidental del tipo de retorno de un método.

Leyton Manuel Espitia Diaz

Escrito por

Leyton Manuel Espitia Diaz

CEO y fundador de Nakanaba

Desarrollador de software y fundador de Nakanaba. Lidera la arquitectura de Vitrin: backend en .NET, aplicaciones en Angular y .NET MAUI, SQL Server y servicios en Cloudflare.

¿Necesitas un equipo .NET para tu proyecto?

Construimos y modernizamos backends en .NET y SQL Server: APIs, integraciones y sistemas empresariales a medida.

Ver desarrollo a medida