Tracking vs NoTracking en EF Core: cuándo usar cada uno
AsNoTracking acelera las lecturas, pero ponerlo en todas partes rompe actualizaciones de forma silenciosa. Qué hace realmente el change tracker y cómo decidir.
Un día alguien lee que AsNoTracking() hace las consultas más rápidas y lo agrega a todas las consultas del proyecto. Las pantallas de listado mejoran. Semanas después, un endpoint de edición deja de guardar cambios: SaveChanges se ejecuta, no lanza ningún error y no actualiza nada.
Las dos cosas tienen la misma causa. Para usar bien AsNoTracking hay que entender qué hace el change tracker y qué se pierde cuando lo apagas.
Qué hace el change tracker
Cuando una consulta devuelve entidades con tracking (el comportamiento por defecto), EF Core:
- Guarda una copia de los valores originales de cada entidad (el snapshot).
- Registra la entidad en un mapa de identidad: si la misma fila aparece dos veces, devuelve la misma instancia.
- Al llamar a
SaveChanges, compara cada entidad contra su snapshot y genera losUPDATEnecesarios.
Eso es lo que permite este código:
var order = await db.Orders.FirstAsync(o => o.Id == id, ct);
order.Status = OrderStatus.Shipped;
await db.SaveChangesAsync(ct); // EF detecta el cambio y genera el UPDATE
El costo: memoria para los snapshots, tiempo para registrar cada entidad y una comparación por propiedad en cada SaveChanges. Con 20 entidades no se nota. Con 20.000 en un reporte, sí.
Qué cambia con AsNoTracking
var orders = await db.Orders
.AsNoTracking()
.Where(o => o.CustomerId == customerId)
.ToListAsync(ct);
EF Core materializa los objetos y se olvida de ellos. No hay snapshot, no hay mapa de identidad y SaveChanges no sabe que existen. Por eso el endpoint de edición del ejemplo dejó de funcionar: modificaba una entidad que el contexto no estaba rastreando.
Hay una diferencia menos obvia: sin mapa de identidad, la misma fila puede aparecer como dos objetos distintos. Si cargas pedidos con su cliente y diez pedidos son del mismo cliente, tendrás diez instancias de ese cliente. Si necesitas una sola instancia por fila sin pagar el tracking completo, existe AsNoTrackingWithIdentityResolution().
Cuándo se gana más con NoTracking
- Listados y búsquedas: muchas filas que solo se van a mostrar.
- Reportes y exportaciones: miles de filas que se leen una vez.
- Endpoints
GETde una API: la entidad se convierte en un DTO y el contexto se libera al terminar la petición.
En esos casos, lo que suele ganar aún más es la proyección:
var list = await db.Orders
.Where(o => o.CustomerId == customerId)
.Select(o => new OrderListItem(o.Id, o.CreatedAt, o.Total, o.Status))
.ToListAsync(ct);
Una proyección a un tipo que no es entidad nunca se rastrea, aunque no escribas AsNoTracking. Y además trae solo las columnas necesarias. Si ya proyectas, AsNoTracking no agrega nada (lo vimos en problemas de rendimiento de EF Core en producción).
NoTracking por defecto: una opción razonable con reglas claras
En APIs donde la mayoría de operaciones son lecturas, se puede invertir el valor por defecto:
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString)
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
Y pedir tracking explícitamente donde se va a modificar:
var order = await db.Orders
.AsTracking()
.FirstAsync(o => o.Id == id, ct);
order.Status = OrderStatus.Shipped;
await db.SaveChangesAsync(ct);
Funciona bien si todo el equipo lo sabe. El riesgo es el del ejemplo inicial: alguien escribe una consulta de edición sin AsTracking(), y SaveChanges no falla, simplemente no hace nada. Una prueba de integración por cada comando de escritura atrapa este error antes de que llegue a producción.
Actualizar sin cargar primero
Con una entidad sin tracking (o recibida desde el cliente), puedes adjuntarla al contexto:
db.Orders.Update(order); // marca TODAS las propiedades como modificadas
await db.SaveChangesAsync(ct);
Update genera un UPDATE con todas las columnas, incluidas las que no cambiaron. Eso puede sobrescribir datos que otro usuario modificó mientras tanto. Si solo cambia un campo, es más seguro y preciso:
await db.Orders
.Where(o => o.Id == id)
.ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, OrderStatus.Shipped), ct);
El trade-off
| Tracking | NoTracking | |
|---|---|---|
| Memoria y CPU por entidad | Mayor | Menor |
SaveChanges detecta cambios |
Sí | No |
| Una instancia por fila | Sí | No (salvo con identity resolution) |
| Riesgo | Contextos que crecen sin control | Cambios que no se guardan sin aviso |
El change tracker también tiene un costo acumulativo: un DbContext que vive mucho tiempo (por ejemplo, en un proceso que recorre miles de registros en un bucle) va acumulando entidades rastreadas y cada SaveChanges se vuelve más lento. En esos procesos, conviene usar un contexto nuevo por lote o llamar a ChangeTracker.Clear().
Cuándo no preocuparse
En una pantalla que carga un formulario con una entidad y sus diez líneas, el costo del tracking es irrelevante. Quitarlo no va a mejorar nada que un usuario pueda notar y agrega una posibilidad de error. Optimiza donde hay volumen.
Conclusión
AsNoTracking no es una optimización para aplicar en todo el proyecto, es una decisión por consulta: ¿esta consulta va a modificar algo? Si no, sin tracking o, mejor aún, con proyección. Si sí, con tracking y con una prueba que verifique que el cambio realmente se guarda.
¿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