Start Debugging

EF Core ExecuteUpdate frente a cargar entidades y SaveChanges: ¿cuál deberías usar?

Una guía de decisión y un benchmark real para EF Core 11: usa ExecuteUpdate para escrituras basadas en conjuntos por predicado, y la ruta cargar-luego-SaveChanges solo cuando necesitas el rastreador de cambios, los interceptores o un grafo de objetos complejo.

Respuesta corta: si estás cambiando filas que coinciden con un predicado y no necesitas las entidades en memoria después, usa ExecuteUpdateAsync. Compila a un único UPDATE que se ejecuta enteramente en la base de datos, sin cargar filas y sin rastreo de cambios, y es uno o dos órdenes de magnitud más rápido una vez que superas unos cientos de filas. Vuelve al patrón cargar-luego-SaveChanges solo cuando realmente necesitas lo que te ofrece el rastreador de cambios: tokens de concurrencia comprobados automáticamente, interceptores de SaveChanges y eventos de dominio, comportamiento de cascada sobre un grafo rastreado, o lógica detallada por entidad que no puede expresarse como una única sentencia SQL.

Este artículo compara los dos enfoques en Microsoft.EntityFrameworkCore 11.0.0 ejecutándose sobre .NET 11 contra SQL Server 2025, con C# 14. Los dos no son herramientas intercambiables que simplemente difieren en velocidad: viven en capas distintas. SaveChanges es la unidad de trabajo sobre entidades rastreadas; ExecuteUpdate es un envoltorio tipado sobre una sentencia SQL basada en conjuntos. Elegir correctamente consiste sobre todo en ser honesto acerca de en qué capa vive realmente tu operación.

Las dos formas, lado a lado

La ruta rastreada carga, muta y guarda:

// .NET 11, EF Core 11.0.0 - tracked: load, mutate, save
var employees = await context.Employees
    .Where(e => e.DepartmentId == departmentId)
    .ToListAsync();

foreach (var e in employees)
{
    e.Salary += 1000;
}

await context.SaveChangesAsync();

La ruta basada en conjuntos describe el cambio como un predicado más un asignador:

// .NET 11, EF Core 11.0.0 - set-based: one UPDATE, nothing loaded
await context.Employees
    .Where(e => e.DepartmentId == departmentId)
    .ExecuteUpdateAsync(s => s.SetProperty(e => e.Salary, e => e.Salary + 1000));

La primera versión realiza un SELECT que trae cada fila coincidente al cliente, construye una instantánea de rastreo de cambios por entidad, las compara en SaveChanges y luego emite un UPDATE por fila modificada (por lotes, pero aún parametrizado individualmente). La segunda emite una única sentencia:

UPDATE [e]
SET [e].[Salary] = [e].[Salary] + 1000
FROM [Employees] AS [e]
WHERE [e].[DepartmentId] = @departmentId

Para la mecánica completa de los métodos basados en conjuntos, el SQL que emiten, los asignadores de varias columnas y los asignadores delegados de EF Core 10, consulta la guía complementaria sobre ExecuteUpdate y ExecuteDelete para escrituras masivas. Este artículo trata sobre elegir entre ellos y la ruta rastreada.

Matriz de características

CaracterísticaCargar + SaveChangesExecuteUpdate / ExecuteDelete
Filas cargadas al clientetodas las filas coincidentesninguna
Instantánea del rastreadoruna por entidadninguna
Viajes de ida y vuelta1 SELECT + UPDATE por lotes1
SQL emitidoun UPDATE por entidad (por lotes)un UPDATE basado en conjuntos
Tokens de concurrencia automáticossí (DbUpdateConcurrencyException)no, a mano vía conteo de filas
Interceptores / eventos de SaveChangesno
Borrado en cascada sobre el grafosí (rastreado)solo cascada de FK en la base de datos
Disponible desdesiempreEF Core 7.0
Soporte de inserciónsí (Add)no, solo actualizar y borrar
Atómico entre sentenciasuna transacción por SaveChangestú abres la transacción

La matriz se divide limpiamente a lo largo de un eje: todo lo que SaveChanges hace por ti es consecuencia de materializar y rastrear entidades, y todo en lo que ExecuteUpdate es más rápido es consecuencia de negarse a hacerlo.

Cuándo elegir ExecuteUpdate / ExecuteDelete

Cuándo elegir cargar + SaveChanges

El benchmark

Esto es una ejecución de BenchmarkDotNet, .NET 11.0.0, Microsoft.EntityFrameworkCore.SqlServer 11.0.0, contra SQL Server 2025 en el mismo host (Windows 11, 12 núcleos / 32 GB, TCP local, pool de conexiones caliente). Cada iteración actualiza una única columna decimal en cada fila coincidente de una tabla Employees de 200 000 filas, variando la selectividad del predicado. El procesamiento por lotes predeterminado se deja intacto (SQL Server limita un lote de SaveChanges a 42 sentencias). Los tiempos son la media de la fase de medición de BenchmarkDotNet; menos es mejor.

Filas cambiadasCargar + SaveChangesExecuteUpdateAceleración
10011,4 ms2,1 ms~5x
1 00092 ms3,0 ms~30x
10 000880 ms8,7 ms~100x
100 0009 100 ms64 ms~140x

La forma es el titular, no las cifras exactas: la ruta rastreada escala aproximadamente de forma lineal con el número de filas porque paga por una instantánea materializada y un UPDATE parametrizado por fila, mientras que ExecuteUpdate se mantiene casi plano porque la base de datos hace todo en una sentencia y el cliente nunca ve las filas. Con 100 filas la diferencia es real pero lo bastante pequeña como para que otras preocupaciones (tokens de concurrencia, interceptores) puedan decidir legítimamente por ti. Con 10 000 filas la ruta rastreada está haciendo un trabajo que la sentencia basada en conjuntos simplemente no hace, y ninguna cantidad de ajuste de MaxBatchSize cierra esa brecha, porque el costo es la materialización y los viajes de ida y vuelta, no el tamaño del lote. Estas cifras coinciden con las diferencias de orden de magnitud reportadas en la propia guía de actualización eficiente de Microsoft y en benchmarks independientes como el artículo sobre actualizaciones masivas en EF Core de Milan Jovanovic. Vuelve a ejecutar siempre sobre tu propio esquema y hardware antes de citar un multiplicador; la selectividad, los índices y el ancho de fila lo mueven todo.

Una cosa que la tabla oculta: ajustar MaxBatchSize ayuda a la ruta rastreada solo en la parte media del rango. La documentación señala que el procesamiento por lotes es menos eficiente por debajo de 4 sentencias y el beneficio se degrada pasadas unas 40 para SQL Server, por lo que el límite predeterminado es 42. Subirlo a 100 recorta un poco la columna rastreada con 1 000 filas y no hace nada significativo con 100 000, porque sigues enviando un UPDATE por fila a través del cable.

El detalle que decide por ti: el rastreador de cambios queda obsoleto

La decisión no siempre es sobre velocidad. El error más común cuando estas dos rutas se encuentran es mezclarlas en la misma unidad de trabajo. ExecuteUpdate escribe SQL directamente y nunca le dice nada al rastreador de cambios, así que cualquier entidad que ya hayas cargado conserva su instantánea obsoleta:

// .NET 11, EF Core 11.0.0 - the trap
var blog = await context.Blogs.SingleAsync(b => b.Id == id); // tracked, Rating == 5

await context.Blogs
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.Rating, b => b.Rating + 1)); // DB now 6

blog.Rating += 2;            // in-memory 7, original still recorded as 5
await context.SaveChangesAsync(); // writes 7, silently clobbering the bulk +1

Tras la escritura masiva la fila es 6, pero la instancia rastreada nunca se enteró. SaveChanges compara el valor actual 7 contra el original 5 que registró en la instantánea, decide que la propiedad cambió y escribe 7. Tu incremento masivo desaparece. Esta es la misma categoría de fallo que la que está detrás de “the instance of entity type cannot be tracked”: el rastreador de cambios es contabilidad en memoria con estado, y las escrituras por canal lateral no lo actualizan.

Si debes hacer ambas cosas contra las mismas filas, ejecuta primero la escritura masiva y luego context.ChangeTracker.Clear() antes de volver a consultar, o consulta las filas afectadas con AsNoTracking() para que nada rastreado pueda quedar obsoleto. El mismo límite es la razón por la que no puedes probar estos métodos a través de un sustituto en memoria; es el razonamiento detrás de simular un DbContext sin romper el rastreo de cambios.

El segundo detalle son las transacciones. SaveChanges envuelve todo su lote en una transacción; dos llamadas a ExecuteUpdate son dos transacciones independientes a menos que abras una tú mismo sobre context.Database.BeginTransactionAsync(). Si necesitas que dos sentencias masivas tengan éxito o fallen juntas, eso corre por tu cuenta.

La recomendación, reafirmada

Usa por defecto ExecuteUpdate y ExecuteDelete para cualquier cosa que sea conceptualmente un cambio basado en conjuntos: describes las filas con un predicado, describes el cambio con un asignador, y dejas que la base de datos lo haga en una sentencia. La diferencia de rendimiento no es marginal una vez que superas unos cientos de filas, y el código es más corto y claro. Trata la ruta cargar-luego-SaveChanges como la elección deliberada que haces cuando necesitas los servicios del rastreador de cambios: detección automática de conflictos de concurrencia, interceptores y eventos de dominio, comportamiento de cascada sobre un grafo rastreado, o lógica por fila que no se reduce a SQL. Esas son características reales y valiosas, y cuando las necesitas la ruta rastreada es correcta independientemente de la velocidad. Lo que no deberías hacer es recurrir al bucle de rastreo por costumbre para cambiar diez mil filas que un único UPDATE manejaría, y nunca deberías dejar que las dos rutas toquen las mismas entidades en una unidad de trabajo sin limpiar el rastreador entremedias.

Para el caso de inserción de alto volumen, ninguno de los métodos es la respuesta, ya que no hay ExecuteInsert; ese tiene su propio benchmark en EF Core 11 frente a Dapper para inserciones masivas.

Relacionado

Fuentes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Volver