.NET y backend

Cómo optimizar consultas SQL desde Entity Framework Core

Ya encontraste la consulta lenta; ahora corrígela desde EF Core: índices en el modelo, tipos correctos, consultas compiladas, SQL directo y pooling.

Por Leyton Manuel Espitia Diaz 5 min de lectura

Fragmento de código C# que configura un índice y el tipo varchar desde el modelo de EF Core

Ya sabes cuál es la consulta lenta: la encontraste en Query Store o en el monitoreo, y su plan muestra un scan donde esperabas un seek. El siguiente paso es corregirla, y en un proyecto con EF Core eso significa cambiar código C#, no solo el SQL.

Este artículo va de lo más simple a lo más invasivo. En la mayoría de los casos, el problema se resuelve en los primeros pasos.

1. Proyecta y filtra en la base de datos

Lo vimos en detalle en problemas de rendimiento de EF Core en producción, pero vale repetirlo porque resuelve la mayoría de casos: Select a un tipo con solo las columnas necesarias, y todos los filtros antes de materializar.

var rows = await db.Orders
    .Where(o => o.CustomerId == customerId && o.CreatedAt >= from)
    .OrderByDescending(o => o.CreatedAt)
    .Select(o => new OrderRow(o.Id, o.CreatedAt, o.Total))
    .Take(50)
    .ToListAsync(ct);

Con esa forma, un índice sobre (CustomerId, CreatedAt) que incluya Total cubre la consulta completa.

2. Declara los índices en el modelo

Si el índice existe solo en producción porque alguien lo creó a mano, la próxima base de datos (pruebas, un cliente nuevo, una restauración) no lo tendrá. Decláralo en el modelo para que viaje con las migraciones:

modelBuilder.Entity<Order>(e =>
{
    e.HasIndex(o => new { o.CustomerId, o.CreatedAt })
     .HasDatabaseName("IX_Orders_CustomerId_CreatedAt")
     .IncludeProperties(o => o.Total); // INCLUDE en SQL Server

    e.HasIndex(o => o.CreatedAt)
     .HasFilter("[Status] = 'Pending'"); // índice filtrado
});

3. Tipos de columna correctos (adiós a CONVERT_IMPLICIT)

Por defecto, EF Core mapea string a nvarchar(max). Eso tiene dos problemas: una columna max no puede ser clave de un índice, y si la columna real es varchar, el parámetro llega como nvarchar y provoca una conversión implícita que impide el seek.

modelBuilder.Entity<Product>(e =>
{
    e.Property(p => p.Sku).HasMaxLength(30).IsUnicode(false); // varchar(30)
    e.Property(p => p.Name).HasMaxLength(200);                 // nvarchar(200)
    e.Property(p => p.Price).HasPrecision(18, 2);              // decimal(18,2)
});

Para no repetirlo en cada entidad, se puede definir una convención global:

protected override void ConfigureConventions(ModelConfigurationBuilder builder)
{
    builder.Properties<string>().HaveMaxLength(256);
    builder.Properties<decimal>().HavePrecision(18, 2);
}

4. Revisa qué SQL genera cada operador de LINQ

Algunas expresiones de LINQ generan SQL que no puede usar índices:

// ❌ Se traduce a una función sobre la columna
.Where(o => o.CreatedAt.Date == today)

// ✅ Un rango sobre la columna
.Where(o => o.CreatedAt >= today && o.CreatedAt < today.AddDays(1))

// ❌ LIKE '%texto%': siempre recorre el índice completo
.Where(p => p.Name.Contains(term))

// ✅ LIKE 'texto%': puede usar un índice sobre Name
.Where(p => p.Name.StartsWith(term))

Para búsquedas de texto libre sobre muchas filas, Contains no escala. Ahí la herramienta correcta es la búsqueda de texto completo (full-text search) o un motor de búsqueda dedicado.

ToQueryString() sobre la consulta muestra el SQL sin ejecutarla; úsalo cada vez que tengas dudas de cómo se traduce algo.

5. Consultas compiladas para el camino más caliente

Cada vez que EF Core ejecuta una consulta LINQ, tiene que traducir el árbol de expresión a SQL. EF Core guarda esas traducciones en caché, pero aun así hay un costo por buscar en ella. En consultas que se ejecutan miles de veces por segundo, una consulta compilada elimina ese costo:

private static readonly Func<AppDbContext, int, Task<ProductPrice?>> GetPrice =
    EF.CompileAsyncQuery((AppDbContext db, int id) =>
        db.Products
          .Where(p => p.Id == id)
          .Select(p => new ProductPrice(p.Id, p.Price))
          .FirstOrDefault());

var price = await GetPrice(db, productId);

La ganancia es pequeña por ejecución, así que solo tiene sentido en rutas con mucho tráfico. Para el resto, agrega complejidad sin beneficio visible.

6. Pooling de DbContext

Crear un DbContext por petición tiene un costo de inicialización. Con AddDbContextPool, EF Core reutiliza instancias:

builder.Services.AddDbContextPool<AppDbContext>(o => o.UseSqlServer(connectionString), poolSize: 256);

La condición: el contexto no puede guardar estado propio entre peticiones (por ejemplo, un TenantId asignado en el constructor), porque la instancia se reutiliza. Si tu contexto depende de algo por petición, el pooling requiere más cuidado.

7. Cuando LINQ no alcanza: SQL directo

Algunas consultas son mucho más claras en SQL: window functions, MERGE, CTE recursivas o hints específicos. EF Core permite ejecutarlas sin abandonar el contexto:

var ranking = await db.Database
    .SqlQuery<CustomerRank>($"""
        SELECT CustomerId, SUM(Total) AS Total,
               RANK() OVER (ORDER BY SUM(Total) DESC) AS Position
        FROM Sales.Orders
        WHERE CreatedAt >= {from}
        GROUP BY CustomerId
        """)
    .ToListAsync(ct);

SqlQuery con una cadena interpolada parametriza los valores automáticamente: {from} se convierte en un parámetro, no se concatena. Para tipos que no son entidades está disponible desde EF Core 8.

Y si el proyecto ya tiene muchas consultas de lectura en SQL puro, usar Dapper para esas y EF Core para las escrituras es una combinación muy común y perfectamente válida.

El trade-off

Cuanto más bajas en esta lista, más acoplas el código a SQL Server y a la forma concreta de la consulta. Un índice o una proyección son cambios baratos y seguros. Una consulta compilada o SQL directo son más rápidos, pero más difíciles de mantener y de cambiar después.

Cuándo no optimizar

Si la consulta corre en un proceso administrativo una vez por hora y tarda 300 ms, déjala. Optimiza donde el costo se multiplica: rutas con mucho tráfico, listados que todos los usuarios abren o consultas que bloquean a otras. Y mide antes y después: una optimización que no se puede medir es una suposición.

Conclusión

La mayoría de consultas lentas con EF Core se corrigen con tres cosas: proyectar, declarar el índice correcto y usar los tipos de columna adecuados. El resto (consultas compiladas, pooling, SQL directo) son herramientas para casos concretos, no para aplicar en todo el proyecto.

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