LINQ lleva con nosotros desde C# 3.0 y a estas alturas la mayoría de desarrolladores sabe encadenar un Where con un Select. Pero hay una brecha bastante grande entre usar LINQ y usarlo bien. Este artículo cubre las partes que suelen generar bugs silenciosos o consultas lentas: la ejecución diferida, el problema N+1 con EF Core, los métodos nuevos de .NET 9 y la diferencia real entre IQueryable<T> e IEnumerable<T>.
Ejecución diferida vs inmediata: el malentendido más común
Cuando escribes una consulta LINQ, no estás ejecutando nada. Solo estás definiendo cómo se va a ejecutar cuando alguien itere el resultado. Esto se llama ejecución diferida y es la fuente del error más frecuente con LINQ.
Los operadores diferidos son los que encadenas sin resultados inmediatos: Where, Select, OrderBy, Skip, Take. Los operadores inmediatos son los que fuerzan la ejecución: ToList(), ToArray(), Count(), First(), Sum().
var query = productos.Where(p => p.Activo); // no ejecuta nada aquí
foreach (var p in query) { /* aquí sí */ }
foreach (var p in query) { /* y aquí otra vez, ejecuta de nuevo */ }
Si iteras la misma query dos veces sin materializarla antes, la ejecutas dos veces. Con una lista en memoria tiene poco coste, pero si detrás hay una consulta a base de datos, estás lanzando dos queries SQL sin darte cuenta.
La regla es sencilla: si vas a usar el resultado más de una vez, materializa con ToList() o ToArray() antes de la primera iteración.
var productosActivos = productos.Where(p => p.Activo).ToList(); // una sola vez
foreach (var p in productosActivos) { /* ... */ }
var total = productosActivos.Count; // sin query adicional
El problema N+1 con Entity Framework Core
Con EF Core, el error N+1 es el que más daño hace en producción y el que menos se ve en desarrollo porque los tiempos son bajos cuando hay pocos registros.
Imagina que tienes pedidos y cada pedido tiene una colección de líneas. Si haces esto:
var pedidos = context.Pedidos.Where(p => p.Activo).ToList();
foreach (var pedido in pedidos)
{
Console.WriteLine(pedido.Lineas.Count); // query SQL por cada pedido
}
EF Core lanza una query para traer los pedidos y luego una más por cada pedido para cargar sus líneas. Con 200 pedidos, son 201 queries. La solución es Include:
var pedidos = context.Pedidos
.Where(p => p.Activo)
.Include(p => p.Lineas)
.ToList(); // una sola query con JOIN
Pero cuidado: si solo necesitas algunos campos, proyectar a un DTO es mejor que el Include completo. Cargas menos datos y EF Core genera SQL más eficiente:
var resumen = context.Pedidos
.Where(p => p.Activo)
.Select(p => new PedidoDto
{
Id = p.Id,
Cliente = p.Cliente.Nombre,
Total = p.Lineas.Sum(l => l.Precio * l.Cantidad)
})
.ToList();
Para consultas de solo lectura donde no necesitas actualizar los datos después, añade AsNoTracking(). EF Core deja de registrar los cambios en los objetos devueltos y la consulta es notablemente más rápida:
var productos = context.Productos
.AsNoTracking()
.Where(p => p.CategoriaId == categoriaId)
.ToList();
Los métodos nuevos de .NET 9
.NET 9 añadió varios métodos a LINQ que resuelven casos que antes requerían combinaciones de GroupBy y Select bastante verbosas.
CountBy
CountBy cuenta los elementos agrupados por una clave. Antes necesitabas un GroupBy completo para esto:
// Antes
var conteo = pedidos
.GroupBy(p => p.Estado)
.Select(g => new { Estado = g.Key, Total = g.Count() })
.ToList();
// Con .NET 9
var conteo = pedidos.CountBy(p => p.Estado).ToList();
// Devuelve IEnumerable<KeyValuePair<TKey, int>>
AggregateBy
Similar a CountBy pero para cualquier agregación. Útil cuando necesitas sumar, acumular o calcular algo por clave en una sola pasada:
var totalPorCliente = pedidos.AggregateBy(
keySelector: p => p.ClienteId,
seed: 0m,
func: (acumulado, pedido) => acumulado + pedido.Total
);
Index
Index() devuelve cada elemento junto con su posición en la secuencia. Es lo mismo que Select((item, i) => (i, item)) pero más legible:
foreach (var (indice, producto) in productos.Index())
{
Console.WriteLine($"{indice}: {producto.Nombre}");
}
Shuffle
Shuffle() baraja la colección de forma aleatoria y devuelve una nueva secuencia. Viene bien para ordenaciones aleatorias sin tener que recurrir a OrderBy(_ => Guid.NewGuid()), que es un truco bastante feo:
var preguntasAleatorias = preguntas.Shuffle().Take(10).ToList();
Chunk: procesar en lotes
Chunk(n) divide una colección en grupos de n elementos. El caso de uso típico es procesar registros en batches sin cargar todo en memoria de golpe:
var emails = context.Usuarios
.Where(u => u.RecibirNewsletter)
.Select(u => u.Email)
.ToList();
foreach (var lote in emails.Chunk(100))
{
await enviarEmail(lote); // procesa de 100 en 100
}
El último lote puede tener menos de 100 elementos si el total no es múltiplo exacto. Chunk lo gestiona solo.
LINQ con Span<T>: lo que no funciona
LINQ estándar no funciona con Span<T> porque Span<T> no implementa IEnumerable<T>. Si intentas encadenar un Where sobre un span, el compilador te lo impide.
Para operaciones sobre buffers con alto requisito de rendimiento, la alternativa son los métodos de MemoryExtensions, que permiten buscar, contar y operar sobre spans sin allocations:
ReadOnlySpan<int> numeros = stackalloc int[] { 1, 2, 3, 4, 5 };
int indice = numeros.IndexOf(3); // sin LINQ, sin allocations
Fuera de estos casos muy concretos de rendimiento crítico, trabaja con arrays o listas y usa LINQ normal.
Query syntax vs method syntax
LINQ tiene dos formas de escribirse y producen el mismo resultado. La sintaxis de query se parece a SQL:
var resultado =
from p in productos
where p.Precio > 100
orderby p.Nombre
select p;
La sintaxis de métodos encadena llamadas:
var resultado = productos
.Where(p => p.Precio > 100)
.OrderBy(p => p.Nombre);
En el código moderno predomina la sintaxis de métodos. La sintaxis de query puede ser más clara en joins complejos:
var resultado =
from p in productos
join c in categorias on p.CategoriaId equals c.Id
select new { p.Nombre, Categoria = c.Nombre };
Usa la que sea más legible en cada caso. No hay una respuesta única.
IQueryable<T> vs IEnumerable<T>: la diferencia que importa
Esta es la distinción más importante cuando trabajas con EF Core y la que más consultas lentas genera cuando se ignora.
IEnumerable<T> ejecuta en memoria. Si tienes 100.000 usuarios en base de datos y haces esto:
// MAL: trae los 100.000 registros y filtra en C#
var activos = context.Users.ToList().Where(u => u.Active);
EF Core trae todos los registros y luego C# filtra en memoria. Con tablas grandes, esto mata el rendimiento.
IQueryable<T> traduce el LINQ a SQL. El filtro ocurre en la base de datos:
// BIEN: el WHERE está en SQL, solo trae los activos
var activos = context.Users.Where(u => u.Active).ToList();
La trampa está en que context.Users devuelve DbSet<User>, que implementa IQueryable<T>. Mientras encadenes operadores LINQ sin materializar, todo se traduce a SQL. En cuanto llamas a ToList() en medio de la cadena, el resto de la cadena se ejecuta en memoria.
Si recibes una colección como parámetro de tipo IEnumerable<T> en vez de IQueryable<T>, ya estás trabajando en memoria aunque la fuente original fuera EF Core. Ojo con los métodos que reciben IEnumerable cuando la intención es que el filtrado ocurra en SQL.
Métodos de extensión LINQ propios
Puedes añadir tus propios operadores LINQ para hacer los pipelines más expresivos en tu dominio. La firma básica es:
public static class LinqExtensions
{
public static IEnumerable<T> WhereNotNull<T>(
this IEnumerable<T?> source) where T : class
{
return source.Where(x => x is not null)!;
}
}
// Uso
var nombres = usuarios
.Select(u => u.Nombre)
.WhereNotNull()
.ToList();
Tienen sentido cuando una transformación o filtro aparece repetidamente en el código y tiene un nombre claro en tu dominio. Lo que no tiene sentido es crear métodos de extensión para consultas muy específicas de un contexto: eso va mejor como método en el repositorio correspondiente.
Si te interesa ver cómo encaja todo esto con el resto del lenguaje, la historia de C# y LINQ como combinación que definió el lenguaje da bastante contexto. Y si trabajas con ASP.NET Core y Entity Framework, muchas de estas consideraciones de rendimiento son especialmente relevantes en el contexto de APIs con carga real.
Imagen: Pexels / Andrey Matveev
