
En el artículo anterior vimos cómo EF Core 10 permite mapear un Complex Type completo a una única columna JSON:
blog.ComplexProperty(
x => x.Details,
details => details.ToJson());Eso nos permite mantener un modelo C# fuertemente tipado:
Blog
└── Details
├── Title
├── Category
├── Views
└── Featuredmientras SQL Server almacena Details como un documento.
Pero almacenar y consultar JSON es solamente una parte del problema.
La siguiente pregunta es:
¿Qué ocurre cuando necesito actualizar una propiedad dentro de ese documento para muchas filas?
Por ejemplo:
Incrementar Views de todos los blogs .NET.Podríamos cargar todos los blogs, modificar cada objeto y ejecutar SaveChangesAsync().
Pero EF Core tiene otra herramienta para actualizaciones conocidas de antemano:
ExecuteUpdateAsync()ExecuteUpdate existe desde EF Core 7.
La novedad de EF Core 10 es que ahora podemos utilizarlo también para propiedades dentro de columnas JSON relacionales.
Nuestro modelo
Vamos a utilizar:
public sealed class Blog
{
public int Id { get; set; }
public required string Name { get; set; }
public required BlogDetails Details { get; set; }
}BlogDetails:
public sealed class BlogDetails
{
public required string Title { get; set; }
public required string Category { get; set; }
public int Views { get; set; }
public bool Featured { get; set; }
}Y lo configuramos como Complex Type almacenado en JSON:
modelBuilder.Entity<Blog>(
blog =>
{
blog.HasKey(x => x.Id);
blog.Property(x => x.Name)
.IsRequired();
blog.ComplexProperty(
x => x.Details,
details => details.ToJson());
});La tabla queda conceptualmente así:
Blogs
├── Id
├── Name
└── Detailsy Details contiene algo equivalente a:
{
"Title": "EF Core Performance",
"Category": ".NET",
"Views": 10,
"Featured": false
}Primero: la forma tradicional con SaveChanges
Supongamos que queremos incrementar las visualizaciones de todos los blogs cuya categoría sea:
.NETUna solución perfectamente válida es:
var blogs =
await _context.Blogs
.Where(blog =>
blog.Details.Category == category)
.ToListAsync();
foreach (var blog in blogs)
{
blog.Details.Views++;
}
await _context.SaveChangesAsync();Funciona.
Pero observemos el recorrido:
base de datos
→ SELECT
→ materializar Blog
→ materializar BlogDetails
→ Change Tracker
→ modificar Views
→ detectar cambios
→ UPDATESi necesitábamos mostrar esos blogs, validar reglas complejas sobre los objetos o modificar un grafo, probablemente sea exactamente lo que queremos.
Pero para una actualización masiva simple estamos trayendo información a memoria que no necesitamos utilizar.
ExecuteUpdate cambia el flujo
Con ExecuteUpdateAsync() podemos expresar directamente la modificación.
Por ejemplo:
var affectedRows =
await _context.Blogs
.Where(blog =>
blog.Details.Category == ".NET")
.ExecuteUpdateAsync(
setters =>
setters.SetProperty(
blog => blog.Details.Views,
100));La operación dice:
Buscar blogs cuya categoría JSON sea .NET
+
poner Details.Views = 100No existe:
ToListAsync()Tampoco:
SaveChangesAsync()ExecuteUpdateAsync() se ejecuta inmediatamente contra la base de datos.
Lo nuevo: la propiedad está dentro de JSON
La parte realmente interesante es:
blog => blog.Details.ViewsViews no es una columna relacional normal.
Forma parte de:
Detailsque está almacenado como JSON.
Hasta EF Core 10, ExecuteUpdateAsync() no soportaba este escenario.
Ahora EF puede traducir el setter sobre una propiedad interna del documento JSON.
La documentación oficial lo resume con esta idea:
await context.Blogs.ExecuteUpdateAsync(
setters =>
setters.SetProperty(
blog => blog.Details.Views,
blog => blog.Details.Views + 1));Filtrar también por una propiedad JSON
Nuestro filtro es:
.Where(blog =>
blog.Details.Category == category)Es decir, tanto la selección de filas como la propiedad modificada pueden depender del contenido del documento:
WHERE
Details.Category
SET
Details.ViewsSeguimos escribiendo LINQ sobre un modelo tipado.
No trabajamos manualmente con:
JSON_VALUE
JSON_MODIFY
modify()EF y el provider se ocupan de producir la operación adecuada.
SQL Server 2025: modify()
Con SQL Server 2025 y el tipo nativo json, Microsoft documenta que una actualización como:
.SetProperty(
blog => blog.Details.Views,
blog => blog.Details.Views + 1)puede generar SQL equivalente a:
UPDATE [b]
SET [Details].modify(
'$.Views',
JSON_VALUE(
[b].[Details],
'$.Views' RETURNING int
) + 1
)
FROM [Blogs] AS [b]La operación se realiza dentro del motor.
No existe un ciclo en C# que materialice cada blog.
¿Y SQL Server anterior a 2025?
EF Core 10 también soporta versiones anteriores de SQL Server, donde el documento se almacena habitualmente como:
nvarchar(max)en lugar del nuevo tipo:
jsonEn esos escenarios el provider utiliza las capacidades JSON disponibles en esa versión del motor.
Por eso el laboratorio incluye primero una prueba muy simple:
.SetProperty(
blog => blog.Details.Views,
100)Esta operación permite comprobar el soporte de ExecuteUpdate sobre la propiedad JSON sin depender de que el nuevo valor lea otra propiedad de la fila.
Incrementar el valor actual
La operación más interesante es:
var affectedRows =
await _context.Blogs
.Where(blog =>
blog.Details.Category == ".NET")
.ExecuteUpdateAsync(
setters =>
setters.SetProperty(
blog => blog.Details.Views,
blog =>
blog.Details.Views + 1));Ahora el nuevo valor depende del valor actual almacenado dentro del documento:
Views = Views + 1No estamos enviando:
Views = 11
Views = 4desde nuestra aplicación.
La expresión completa se traduce y ejecuta en SQL.
Una consideración para SQL Server
Cuando el valor nuevo de una propiedad JSON depende de una expresión sobre otra columna o sobre el valor actual, el provider de SQL Server necesita capacidades modernas del motor.
Para este tipo de operación, SQL Server 2022 o superior es el escenario recomendado.
En SQL Server 2025, además, podemos aprovechar el nuevo tipo nativo json.
Una sola operación puede afectar muchas filas
Nuestros datos de prueba son:
Architecture Notes
Category = .NET
Views = 10
Cloud Journal
Category = Cloud
Views = 25
Legacy Notes
Category = .NET
Views = 3Ejecutamos:
await _context.Blogs
.Where(blog =>
blog.Details.Category == ".NET")
.ExecuteUpdateAsync(
setters =>
setters.SetProperty(
blog => blog.Details.Views,
100));Resultado:
Architecture Notes
Views = 100
Cloud Journal
Views = 25
Legacy Notes
Views = 100No cargamos los dos blogs .NET.
La selección y la actualización ocurrieron en la base.
ExecuteUpdate devuelve cuántas filas modificó
El método devuelve un entero:
var affectedRows =
await query.ExecuteUpdateAsync(...);En nuestro ejemplo:
affectedRows = 2Esto es útil no solamente para métricas.
También puede utilizarse para implementar controles de concurrencia explícitos.
Por ejemplo:
var affectedRows =
await _context.Blogs
.Where(blog =>
blog.Id == id &&
blog.Version == expectedVersion)
.ExecuteUpdateAsync(...);
if (affectedRows == 0)
{
// El registro ya cambió o no existe.
}A diferencia de SaveChanges, ExecuteUpdate no implementa automáticamente el control de concurrencia mediante el Change Tracker.
Actualizar más de una propiedad del documento
EF Core 10 permite componer setters.
Por ejemplo:
var affectedRows =
await _context.Blogs
.Where(blog =>
blog.Details.Category == ".NET")
.ExecuteUpdateAsync(
setters =>
{
setters.SetProperty(
blog =>
blog.Details.Featured,
true);
setters.SetProperty(
blog =>
blog.Details.Views,
500);
});Conceptualmente:
Details.Featured = true
Details.Views = 500para todas las filas que coincidan con el filtro.
Seguimos sin cargar ninguna entidad.
El Change Tracker no participa
Este punto es especialmente importante.
Supongamos:
var blog =
await _context.Blogs
.SingleAsync(
currentBlog =>
currentBlog.Id == 1);El objeto queda trackeado.
Imaginemos que:
blog.Details.Views = 10Ahora ejecutamos:
await _context.Blogs
.Where(currentBlog =>
currentBlog.Id == 1)
.ExecuteUpdateAsync(
setters =>
setters.SetProperty(
currentBlog =>
currentBlog.Details.Views,
110));La base de datos ahora contiene:
Views = 110Pero la instancia que ya teníamos en memoria continúa mostrando:
Views = 10hasta que hagamos algo como:
await _context.Entry(blog)
.ReloadAsync();Después del reload:
Views = 110La razón es simple:
ExecuteUpdate
→ no utiliza el Change TrackerNo mezclar sin entender qué está pasando
Esta característica tiene una consecuencia importante.
Podemos tener:
base de datos = 110mientras una entidad trackeada mantiene:
objeto en memoria = 10Si después modificamos esa entidad y ejecutamos SaveChanges(), podemos sobrescribir una actualización realizada mediante ExecuteUpdate.
Por eso conviene evitar mezclar alegremente:
entidades trackeadas
+
ExecuteUpdate
+
SaveChangesdentro del mismo flujo sin una estrategia clara.
SaveChanges y ExecuteUpdate no compiten
No se trata de reemplazar uno con otro.
SaveChanges
Es una buena opción cuando:
cargamos una entidad;
el usuario modifica datos;
no sabemos de antemano qué cambió;
trabajamos con grafos;
necesitamos Change Tracker;
queremos concurrencia automática.ExecuteUpdate
Es especialmente útil cuando:
sabemos exactamente qué propiedad cambiar;
tenemos un filtro definido;
podemos afectar muchas filas;
no necesitamos materializar objetos;
queremos expresar un UPDATE directo.Por ejemplo:
Incrementar estadísticas.
Marcar documentos.
Cambiar flags.
Actualizar estados.
Aplicar un valor masivamente.Una limitación importante: Complex Types, no Owned Types
El soporte de EF Core 10 para ExecuteUpdate sobre JSON tiene una condición explícita.
El objeto debe estar modelado como:
Complex TypeNo funciona si ese contenido JSON está modelado como:
Owned Entity TypeEsta diferencia vuelve a reforzar algo que vimos en el segundo artículo de la serie.
Los Complex Types tienen semántica de valor y EF Core 10 los está convirtiendo en el modelo principal para representar datos documentales dentro de entidades relacionales.
El SQL importa
En este laboratorio conviene tener habilitado:
"Microsoft.EntityFrameworkCore.Database.Command": "Information"porque queremos observar el SQL real.
ExecuteUpdateAsync() ejecuta directamente la operación.
A diferencia de una consulta:
IQueryable<T>no tenemos una consulta pendiente sobre la que simplemente llamar a:
ToQueryString()Por eso el Output de EF es una evidencia especialmente útil para este laboratorio.
El laboratorio
La estructura es:
EfCore10Labs
└── 04-ExecuteUpdateJson
└── ExecuteUpdateJson.ApiStack:
.NET 10
EF Core 10.0.12
ASP.NET Core Web API
SQL ServerEndpoints principales:
POST /api/blogs/seed
POST /api/blogs/reset
GET /api/blogs
POST /api/blogs/savechanges/increment-views
POST /api/blogs/executeupdate/set-views
POST /api/blogs/executeupdate/increment-views
POST /api/blogs/executeupdate/feature-category
POST /api/blogs/change-tracker-demo/{id}El endpoint de reset existe únicamente para que las pruebas puedan repetirse desde el mismo estado inicial.
Comparación final
SaveChanges
SELECT
↓
materialización
↓
Change Tracker
↓
modificación en memoria
↓
SaveChanges
↓
UPDATEExecuteUpdate
LINQ filter
↓
ExecuteUpdate
↓
UPDATENo necesitamos:
materializar
trackear
SaveChangesConclusión
La novedad no es ExecuteUpdate.
Esa API existe desde EF Core 7.
Lo nuevo en EF Core 10 es que la frontera de ExecuteUpdate llega también al modelo documental dentro de bases relacionales.
Ahora podemos escribir:
.SetProperty(
blog => blog.Details.Views,
100)aunque Views esté dentro de un Complex Type almacenado como JSON.
También podemos expresar:
.SetProperty(
blog => blog.Details.Views,
blog => blog.Details.Views + 1)y delegar la actualización al motor.
Eso nos permite combinar:
modelo C# fuertemente tipado
+
JSON dentro de SQL Server
+
actualizaciones masivas
+
sin materializaciónPero ExecuteUpdate no utiliza el Change Tracker, se ejecuta inmediatamente y no reemplaza a SaveChanges.
La decisión depende del tipo de operación.
Si necesitamos trabajar con objetos y cambios de dominio:
SaveChangesSi sabemos exactamente qué filas y propiedades queremos modificar:
ExecuteUpdateEn el próximo artículo vamos a continuar sobre esta API para estudiar otra mejora de EF Core 10:
Dynamic ExecuteUpdate.