Entity Framework Core a escala: por qué tu app se cae a pedazos con millones de registros (y cómo no dejar que pase)

Cuando estás trabajando en proyectos pequeños o medianos con Entity Framework Core, todo fluye de lujo: consultas expresivas, código limpio, productividad por las nubes.

Y de repente… llegan los millones de registros.

No es que los datos crezcan poco a poco y te dé tiempo a reaccionar. No. Un día estás cómodo con 50 000 filas y al siguiente tu aplicación empieza a sufrir: consultas que antes iban en milisegundos ahora tardan segundos (o minutos), el SaveChanges() se convierte en una tortura, la memoria se dispara y aparecen timeouts por todos lados.

Este artículo es una guía práctica y directa para entender por qué pasa esto y, sobre todo, qué puedes hacer hoy mismo para que tu aplicación siga respirando cuando los datos se ponen serios. Actualizado a 2026 con lo mejor que traen EF Core 8 y 9.

1. ¿Por qué se rompe todo cuando hay muchos datos?

EF Core no está mal hecho. Simplemente su diseño por defecto está pensado para comodidad, no para escala extrema sin ajustes.

El gran culpable: el Change Tracker

Cada vez que cargas una entidad, EF Core la mete en su Change Tracker. Ese sistema:

  • recuerda el estado original de cada objeto
  • detecta qué cambió
  • genera los INSERT/UPDATE/DELETE correspondientes

Suena genial… hasta que tienes cientos de miles o millones de entidades en memoria. El coste del Change Tracker crece casi linealmente (muchas operaciones internas son O(n)). Con 10 registros ni lo notas. Con 100 000 ya molesta. Con varios millones… es un cuello de botella brutal.

Pequeño vs grande: cómo cambia el comportamiento

EscenarioQué pasa realmente
Proyecto pequeñoTodo rápido, latencias bajas, GC casi invisible
Dataset grandeGC muy frecuente, CPU alta, consultas se ralentizan
Operaciones masivasSaveChanges() puede pasar de segundos a minutos (o peor)

Señales de que algo va mal (tu radar personal)

  • La memoria de la aplicación sube sin parar (sobre todo dentro de bucles)
  • El Garbage Collector está trabajando como loco
  • CPU alta solo por operaciones de EF
  • Ves consultas lentas en los logs de SQL
  • Timeouts en operaciones que «deberían ser rápidas»

Si ves dos o más de estas… es hora de actuar.

2. Patrones de consulta que sí escalan

Regla de oro para lecturas: AsNoTracking()

Si solo vas a leer y no vas a modificar nada, siempre pon:

var data = context.Orders
    .AsNoTracking()
    .Where(x => x.Status == "Completed")
    .ToList();

Esto le dice a EF: «no me las metas en el Change Tracker». Menos memoria, menos CPU, mejor rendimiento. Es de las cosas más baratas y efectivas que puedes hacer.

Proyecta a DTOs en vez de traer entidades completas

No traigas el objeto gordo si solo necesitas tres campos:

var orders = context.Orders
    .Select(o => new OrderDto
    {
        Id = o.Id,
        Total = o.Total,
        Date = o.OrderDate
    })
    .ToList();

Menos datos por la red, menos objetos en memoria, menos trabajo para el Change Tracker.

Paginación: olvídate del offset en tablas grandes

Esto NO escala bien:

.Skip(100_000).Take(50)

A partir de cierto punto, el OFFSET grande obliga a escanear todo lo anterior –> lentísimo.

Mejor: keyset pagination (o seek method)

.Where(x => x.Id > lastId)
.OrderBy(x => x.Id)
.Take(50)

Aprovecha índices, es predecible y escala linealmente.

3. Nunca traigas TODO a memoria (por favor)

El clásico error que he visto mil veces:

var all = context.Logs.ToList();

Con 5 millones de filas –> adiós memoria, hola OutOfMemoryException o GC pausando todo durante segundos.

La forma adulta: procesar por lotes

int batchSize = 1000;
long lastId = 0;

while (true)
{
    var batch = context.Logs
        .Where(x => x.Id > lastId)
        .OrderBy(x => x.Id)
        .Take(batchSize)
        .AsNoTracking()
        .ToList();

    if (!batch.Any()) break;

    // Procesar el batch (p.ej. enviar a un queue, calcular algo, etc.)
    lastId = batch.Max(x => x.Id);
}

Este patrón te salva la vida cuando tienes que tocar millones de filas.

Cómo cazar consultas problemáticas

  • Activa logging de EF (con cuidado en producción)
  • Usa SQL Profiler, pgAdmin, Azure Data Studio, etc.
  • MiniProfiler o Application Insights en la app
  • Busca: Includes enormes, patrones N+1, SELECT * innecesarios

4. Operaciones masivas (bulk): aquí es donde más se sufre

Lo que NO hacer

context.AddRange(muchasEntidades);
context.SaveChanges();

EF genera miles de INSERT individuales + tracking completo. Malísimo.

Un poco mejor: batches con SaveChanges

int batchSize = 1000;

foreach (var chunk in entities.Chunk(batchSize))
{
    context.AddRange(chunk);
    context.SaveChanges();
}

Mejor que nada, pero sigue usando Change Tracker y transacciones por batch.

Lo moderno (EF Core 7+ , mejorado en 8 y 9): ExecuteUpdate / ExecuteDelete

// Marcar como procesados miles o millones de filas
await context.Logs
    .Where(l => l.Created < thresholdDate && !l.Processed)
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(l => l.Processed, true));

// Borrar órdenes antiguas
await context.OldOrders
    .Where(o => o.OrderDate < twoYearsAgo)
    .ExecuteDeleteAsync();

Una sola sentencia SQL, sin cargar nada en memoria, sin Change Tracker. Brutal diferencia.

Limitación: solo puedes hacer lo que SQL entiende directamente. Si necesitas lógica compleja por fila –> toca batch + librería bulk.

Librerías bulk para los casos duros

EFCore.BulkExtensions, Entity Framework Extensions, etc. dan:

  • BulkInsert
  • BulkUpdate
  • BulkDelete
  • BulkMerge / Upsert

Siguen siendo la opción rey para inserts masivos o escenarios muy específicos.

5. Diseño del modelo y del esquema: donde se gana (o se pierde) mucho rendimiento

Índices: no es opcional, es obligatorio

Si vas a filtrar, ordenar o unir por una columna con frecuencia, ponle índice. Punto.

  • Clave primaria –> ya viene indexada (bien)
  • Columnas de WHERE frecuentes (Status, Created, UserId, etc.) –> índice simple
  • Combinaciones comunes (WHERE + ORDER BY) –> índice compuesto
  • Columnas de JOIN –> índice en la FK

Sin índices adecuados, EF Core puede generar consultas decentes… pero la base de datos las ejecuta como escaneo completo. Y con millones de filas, eso es muerte.

Revisa también que las estadísticas de la base estén actualizadas (en SQL Server: UPDATE STATISTICS, en Postgres suele ser automático pero vale la pena chequear).

Separa lo que pesa

Logs, auditoría, historial de cambios, eventos… no los metas en la misma tabla que las entidades principales si puedes evitarlo.

Opciones reales:

  • Tablas separadas (Logs, AuditLog, OrderHistory…)
  • Tablas particionadas (por fecha, por tenant…)
  • Bases de datos separadas para escritura vs analítica (más adelante tocamos CQRS)

Esto evita que una consulta pesada de reporting arrastre a toda tu aplicación transaccional.

Denormalización controlada (sí, a veces hay que hacerlo)

Para reporting crítico o consultas muy frecuentes, duplicar datos puede ser la opción más barata.

Ejemplos comunes:

  • Campo TotalConImpuestos calculado y guardado
  • Tabla de «OrderSummary» con datos precalculados

Desde EF Core 7+ puedes usar columnas JSON de forma nativa (muy útil en Postgres con jsonb, en SQL Server con NVARCHAR(MAX) + chequeo JSON):

modelBuilder.Entity<Order>()
    .Property(o => o.Details)
    .HasColumnType("jsonb");  // Postgres
    // o en SQL Server: .HasColumnType("nvarchar(max)");

Esto te permite guardar estructuras complejas sin crear 20 columnas extras.

6. Separación ligera de lecturas y escrituras (CQRS «light»)

No necesitas arquitectura de microservicios ni Event Sourcing para beneficiarte de esto.

Idea simple:

  • Escritura: usa EF Core normal (con Change Tracker, validaciones, lógica de negocio)
  • Lectura: usa lo más rápido y liviano posible

Opciones para lecturas:

  • SQL crudo con FromSqlRaw() o FromSqlInterpolated()
  • Dapper (muy liviano y rápido)
  • Vistas o materialized views en la base de datos
  • Segundo DbContext optimizado solo para lectura

Ejemplo de separación de contextos:

// WriteDbContext --> Change Tracking activado, con todas las entidades
public class WriteDbContext : DbContext { ... }

// ReadDbContext --> AsNoTracking por defecto en el constructor
public class ReadDbContext : DbContext
{
    public ReadDbContext(DbContextOptions options) : base(options)
    {
        ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
    }
}

En tu DI:

services.AddDbContext<WriteDbContext>(...);
services.AddDbContext<ReadDbContext>(...);

Y usas el que corresponda según el caso.

Bonus muy útil: DbContext Pooling para APIs de alto tráfico

En aplicaciones con muchas peticiones concurrentes (Web API, Blazor Server, etc.):

builder.Services.AddDbContextPool<MyDbContext>(options =>
    options.UseSqlServer(connectionString));

Reduce la creación/destrucción constante de DbContexts –> menos presión en GC, mejor throughput bajo carga alta.

7. Caso práctico real: reprocesar millones de registros

El enfoque que todos hacemos la primera vez (y que nunca más deberíamos)

var logs = context.Logs.ToList();

foreach (var log in logs)
{
    log.Processed = true;
}

context.SaveChanges();

Resultado con 3–5 millones de filas:

  • Memoria > 3–5 GB
  • Tiempo: 30 min a varias horas
  • Posible timeout, OutOfMemory, base de datos bloqueada

Enfoque realista y efectivo (2026)

1. Si la actualización es uniforme (mismo cambio para todos los que cumplen condición) –> ExecuteUpdateAsync

await context.Logs
    .Where(l => l.Created < threshold && !l.Processed)
    .ExecuteUpdateAsync(s => s.SetProperty(l => l.Processed, true));

Tiempo típico: segundos a pocos minutos.

2. Si necesitas lógica por registro –> lectura por lotes + procesamiento + escritura optimizada

  • AsNoTracking() + keyset pagination
  • Procesar batch
  • Guardar con ExecuteUpdateAsync cuando sea posible, o con librería bulk

3. Para inserts o upserts masivos –> EFCore.BulkExtensions o similar

Resultados típicos que ves en la vida real (aprox.)

EstrategiaTiempo (millones de filas)Memoria pico
Cargar todo + SaveChanges30–120 minutos>2–5 GB
Batches de 1000 con SaveChanges5–15 minutos300–800 MB
ExecuteUpdateAsync / ExecuteDelete30 segundos – 3 minutos<200 MB
Librería bulk (insert/update)10 segundos – 2 minutos<150 MB

La diferencia no es «un poquito mejor». Es estructural.

8. Anti-patrones que veo todo el tiempo (evítalos)

  • Llamar .ToList() o .ToArray() «por si acaso»
  • Abusar de .Include() y traer medio grafo de entidades
  • Usar EF Core para reporting analítico pesado (dashboards, BI…)
  • Transacciones gigantes que abarcan miles de cambios
  • No poner índices en columnas filtradas
  • Mezclar lecturas pesadas y escrituras en el mismo contexto
  • No usar DbContext Pooling en APIs con alta concurrencia

9. Checklist rápido antes de que tu app escale (o cuando ya esté sufriendo)

  • ¿Estás usando AsNoTracking() en todas las lecturas puras?
  • ¿Proyectas a DTOs o tipos anónimos cuando no necesitas modificar?
  • ¿Usas keyset pagination en vez de offset para páginas profundas?
  • ¿Procesas grandes volúmenes por lotes?
  • ¿Estás aprovechando ExecuteUpdateAsync y ExecuteDeleteAsync?
  • ¿Tienes índices en todas las columnas de WHERE/ORDER BY/JOIN frecuentes?
  • ¿Las estadísticas de la base están actualizadas?
  • ¿Usas librerías bulk para inserts masivos o merges?
  • ¿Has considerado separar lecturas y escrituras (al menos con dos contextos)?
  • ¿Monitoreas logs SQL + métricas de memoria/CPU en producción?

Ajusta EF Core a medida del tamaño de tu App

Entity Framework Core es una herramienta increíble. En proyectos pequeños o medianos te hace sentir como superhéroe: escribes código expresivo, cambias de base de datos en minutos, y todo «simplemente funciona».

Pero cuando los datos pasan de «miles» a «millones», EF Core no se rompe… se comporta exactamente como está diseñado. El problema casi nunca es la librería. Es que seguimos usándola con los mismos patrones que usábamos cuando teníamos 10 000 filas.

La buena noticia: con ajustes relativamente simples (AsNoTracking, proyecciones, paginación keyset, batches, ExecuteUpdate/Delete, índices decentes y, cuando haga falta, una librería bulk) puedes multiplicar el rendimiento por 10×, 50× o incluso 100× sin tener que abandonar EF Core.

La clave está en saber dónde EF Core te da valor (lógica de negocio, transacciones, consistencia) y dónde te está costando caro (lecturas masivas, reporting, operaciones bulk). En esos segundos casos, no dudes en usar herramientas más livianas o SQL directo.

Así que la próxima vez que alguien te diga «es que EF Core no escala», pregúntale cuántos registros tiene y cómo está escribiendo las consultas. Porque la mayoría de las veces no es que EF Core no escale… es que lo estamos usando como si todavía tuviéramos una base de datos de pruebas con 500 filas.

¿Te ha pasado alguna de estas situaciones? ¿Qué truco te ha salvado el pellejo con millones de registros? Cuéntame en los comentarios, que siempre se aprende algo nuevo.

Sigamos codificando.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.