DbSet<TEntity>
Use DbSet<TEntity> as a query root and change-tracking entry point: Add, Find, Update, Remove and Attach, and what SQL each one leads to.
public abstract class DbSet<TEntity> : IQueryable<TEntity>, IAsyncEnumerable<TEntity> where TEntity : class- Added in .NET EF Core 1.0
Parameters
| Parameter | Type | Description |
|---|---|---|
$TEntity | class | An entity type in the context's model. Declaring the DbSet is one way to add the type to the model; types reached through navigations or configured in OnModelCreating are included without one. |
Return value
n/aA queryable root for one entity type in one context, plus methods that put entities into the change tracker in a given state.
Errors
Add and Attach throw InvalidOperationException when another instance with the same key is already tracked. Find throws ArgumentException when the number or types of key values do not match the primary key.
Changelog
- PHP EF Core 5.0Shared-type entity types: Set<TEntity>(string name) for one CLR type mapped more than once, such as Dictionary<string, object> join tables.
A DbSet<TEntity> does two separate jobs. As an IQueryable<TEntity> it is the root of a LINQ query that EF Core translates to SQL when you enumerate it (ToListAsync, SingleAsync, await foreach). Its methods (Add, Remove, Attach, Update) never touch the database: they put entities into the change tracker in a state, and SaveChangesAsync turns states into SQL later.
Methods and what they mean
| Method | Tracker state | SQL at SaveChanges |
|---|---|---|
Add / AddRange | Added (whole reachable graph) | INSERT, generated keys read back |
Attach | Unchanged (Added if the key is unset) | None until you change a property |
Update | Modified, every property | UPDATE of every column |
Remove | Deleted (tracked dependents per cascade rules) | DELETE by key |
Find / FindAsync | Unchanged | SELECT by key, only on a tracker miss |
FindAsync checks the identity map first and only queries when the entity is not already tracked, so two calls with the same key in one request cost one round trip. It always returns a tracked entity and only looks up by primary key. For anything else, or for read-only data, write a query with AsNoTracking. AddAsync exists only for value generators that need the database, such as SQL Server's HiLo; for identity columns and GUID keys use Add.
Declaring the property
public DbSet<Order> Orders { get; set; } works because the base constructor finds and assigns DbSet properties, but under nullable reference types it needs = null!. public DbSet<Order> Orders => Set<Order>(); has no setter, no warning, and returns a cached instance. Both are fine; pick one per codebase.
Update versus load-and-modify
Request bodies bind to a DTO, never to an entity type. Update(entity) on an entity bound from a request body has two problems: it marks every column modified, so a PUT that omits a field writes its default value and silently wipes data; and it writes every mapped column the client chose to send, so a client can set a balance, a role, a tenant id or a status the endpoint never meant to expose (overposting, or mass assignment). Loading with FindAsync and copying the DTO fields you accept sends only those columns and keeps the original values for concurrency checks. Keep Update for entities rebuilt on the server, for example from a disconnected cache, not for request payloads.
Production failure modes
- The table in memory. Passing
db.Productsto a method that takesIEnumerable<Product>and then calling.Whereruns the filter in C#, afterSELECT *of the whole table. Keep the parameterIQueryable<T>or filter before handing it on. - Duplicate tracking.
AttachorUpdateon a detached instance when the same key was already loaded in this context throws "cannot be tracked because another instance with the same key value is already being tracked".
Example
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<CatalogDb>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("Catalog")));
var app = builder.Build();
app.MapPost("/products", async (NewProduct input, CatalogDb db, CancellationToken ct) =>
{
var product = new Product { Sku = input.Sku, Name = input.Name, Price = input.Price };
db.Products.Add(product); // state Added, no SQL yet
await db.SaveChangesAsync(ct); // INSERT ... RETURNING "Id"
return Results.Created($"/products/{product.Id}", new ProductView(product.Id, product.Sku, product.Price));
});
app.MapGet("/products/{id:int}", async (int id, CatalogDb db, CancellationToken ct) =>
await db.Products.AsNoTracking()
.Where(p => p.Id == id)
.Select(p => new ProductView(p.Id, p.Sku, p.Price))
.SingleOrDefaultAsync(ct) is { } view
? Results.Ok(view)
: Results.NotFound());
app.MapPut("/products/{id:int}/price", async (int id, PriceChange input, CatalogDb db, CancellationToken ct) =>
{
var product = await db.Products.FindAsync([id], ct);
if (product is null) return Results.NotFound();
product.Price = input.Price; // only "Price" goes into the UPDATE
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
app.MapDelete("/products/{id:int}", async (int id, CatalogDb db, CancellationToken ct) =>
{
var product = await db.Products.FindAsync([id], ct);
if (product is null) return Results.NotFound();
db.Products.Remove(product); // state Deleted
await db.SaveChangesAsync(ct); // DELETE FROM "Products" WHERE "Id" = @p0
return Results.NoContent();
});
app.Run();
public record NewProduct(string Sku, string Name, decimal Price);
public record PriceChange(decimal Price);
public record ProductView(int Id, string Sku, decimal Price);
public class Product
{
public int Id { get; set; }
public required string Sku { get; set; }
public required string Name { get; set; }
public decimal Price { get; set; }
}
public class CatalogDb(DbContextOptions<CatalogDb> options) : DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
}When to use it
- Creating a product with Add and returning its database-generated id in a 201 Created response
- Changing only the price of a product with FindAsync and a property assignment, so the UPDATE touches one column
- Spotting a reporting helper that takes IEnumerable<Product> and loads the full Products table on every call
Frequently asked questions
FindAsync or FirstOrDefaultAsync?
FindAsync for a primary-key lookup of an entity you will modify: it skips the query when the entity is already tracked. A query with Where plus FirstOrDefaultAsync, ideally AsNoTracking and a projection, for reads and for anything that is not the primary key.
Do I need a DbSet property for every entity?
No. Entity types reached through navigations or configured in OnModelCreating are in the model anyway, and db.Set<T>() gives you a query root for them. Declare DbSets for the aggregate roots you query directly.
When is AddAsync needed?
Only when a value generator must call the database while the entity is added, such as SQL Server HiLo sequences. With identity columns, PostgreSQL sequences or client-generated GUIDs, Add is correct and does no I/O.
More examples
Update on a request body versus load-and-modify
The load-and-modify version costs one extra SELECT but can neither wipe columns the client did not send nor write columns it should not touch, and the snapshot taken at load time means an unchanged value produces no UPDATE at all. Bind request bodies to DTOs and keep <code>Update</code> for entities rebuilt on the server.
using Microsoft.EntityFrameworkCore;
public sealed class CustomerUpdates(ShopDb db)
{
// Wrong: an entity bound from the request body. Every column is written, so an omitted
// Phone becomes null (data wiped), and a client that adds CreditLimit sets it (overposting).
public async Task OverwriteAll(Customer fromRequest, CancellationToken ct)
{
db.Customers.Update(fromRequest);
await db.SaveChangesAsync(ct); // UPDATE "Customers" SET "Email" = @p0, "Name" = @p1, "Phone" = @p2 ...
}
// Only the fields the endpoint accepts are copied; only changed columns are written.
public async Task<bool> ChangeEmail(int id, string email, CancellationToken ct)
{
var customer = await db.Customers.FindAsync([id], ct);
if (customer is null) return false;
customer.Email = email;
await db.SaveChangesAsync(ct); // UPDATE "Customers" SET "Email" = @p0 WHERE "Id" = @p1
return true;
}
}
public class Customer
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Email { get; set; } = "";
public string? Phone { get; set; }
public decimal CreditLimit { get; set; }
}
public class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public DbSet<Customer> Customers => Set<Customer>();
}Pitfall: IEnumerable parameter pulls the whole table
The compiler picks <code>Enumerable.Where</code> or <code>Queryable.Where</code> from the static type, and a <code>DbSet</code> converts silently to <code>IEnumerable<T></code>. Nothing fails in development with 50 rows; production shows a slow endpoint and a memory spike. Logging the SQL during review makes it obvious.
using Microsoft.EntityFrameworkCore;
public static class ProductFilters
{
// Wrong: IEnumerable<T> makes Where a LINQ-to-objects call.
// EF Core runs SELECT * FROM "Products" and filters 2 million rows in memory.
public static List<Product> InStockSlow(IEnumerable<Product> products) =>
products.Where(p => p.StockOnHand > 0).ToList();
// Right: IQueryable<T> keeps building the SQL; the WHERE runs in the database.
public static IQueryable<Product> InStock(this IQueryable<Product> products) =>
products.Where(p => p.StockOnHand > 0);
}
public sealed class StockReport(CatalogDb db)
{
public Task<List<string>> InStockSkus(CancellationToken ct) =>
db.Products.AsNoTracking().InStock().Select(p => p.Sku).ToListAsync(ct);
}
public class Product
{
public int Id { get; set; }
public string Sku { get; set; } = "";
public int StockOnHand { get; set; }
}
public class CatalogDb(DbContextOptions<CatalogDb> options) : DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
}Find hits the tracker; Local shows what is tracked
<code>LoadAsync</code> runs a query purely to populate the tracker, after which <code>FindAsync</code> is a dictionary lookup. The same identity map is why <code>FindAsync</code> can return a row another process deleted a second ago: it never asked the database.
using Microsoft.EntityFrameworkCore;
public sealed class BasketPricing(ShopDb db, ILogger<BasketPricing> log)
{
public async Task<decimal> Total(IReadOnlyList<int> productIds, CancellationToken ct)
{
// One query loads all distinct products into the tracker.
await db.Products.Where(p => productIds.Contains(p.Id)).LoadAsync(ct);
decimal total = 0;
foreach (var id in productIds)
{
// Served from the identity map: no SQL per item, even when ids repeat.
var product = await db.Products.FindAsync([id], ct)
?? throw new InvalidOperationException($"Unknown product {id}");
total += product.Price;
}
// Local reads tracked entities only; it never queries.
log.LogDebug("Tracked products: {Count}", db.Products.Local.Count);
return total;
}
}
public class Product
{
public int Id { get; set; }
public decimal Price { get; set; }
}
public class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
}
Discussion