Expert · Web APIs with ASP.NET Core and EF Core · topic 35 of 43 · 40 min
Entity Framework Core: databases with C#
Explanation
What is an ORM?
Databases store data in tables of rows and columns; C# works with objects. An ORM (Object-Relational Mapper) translates between the two. With EF Core you write db.Products.Where(p => p.Stock == 0) and EF Core generates the SQL, runs it, and turns the rows back into Product objects. You still need to understand SQL to use it well, but you write much less of it.
Setting up
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet tool install --global dotnet-efusing Microsoft.EntityFrameworkCore;
public class Product
{
public int Id { get; set; } // primary key by convention
public string Name { get; set; } = "";
public string Category { get; set; } = "";
public int Stock { get; set; }
public decimal Price { get; set; }
}
public class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
}builder.Services.AddDbContext<ShopDb>(o =>
o.UseSqlite(builder.Configuration.GetConnectionString("Shop")));Migrations: evolving the schema
dotnet ef migrations add InitialCreate
dotnet ef database updateCommit the Migrations folder to Git: it is the history of your schema. When you add a property, run migrations add again with a clear name (AddProductStock) and review the generated code before applying it.
CRUD with EF Core in a minimal API
app.MapGet("/products", async (ShopDb db) =>
await db.Products.AsNoTracking().OrderBy(p => p.Name).ToListAsync());
app.MapGet("/products/{id:int}", async (int id, ShopDb db) =>
await db.Products.FindAsync(id) is Product p ? Results.Ok(p) : Results.NotFound());
app.MapPost("/products", async (Product p, ShopDb db) =>
{
db.Products.Add(p);
await db.SaveChangesAsync(); // INSERT; p.Id is filled in
return Results.Created($"/products/{p.Id}", p);
});
app.MapPut("/products/{id:int}/stock", async (int id, int stock, ShopDb db) =>
{
var p = await db.Products.FindAsync(id);
if (p is null) return Results.NotFound();
p.Stock = stock; // tracked: EF Core notices the change
await db.SaveChangesAsync(); // UPDATE
return Results.NoContent();
});
app.MapDelete("/products/{id:int}", async (int id, ShopDb db) =>
{
var p = await db.Products.FindAsync(id);
if (p is null) return Results.NotFound();
db.Products.Remove(p);
await db.SaveChangesAsync(); // DELETE
return Results.NoContent();
});Change tracking and AsNoTracking
Entities you load are tracked: the DbContext remembers their original values, and SaveChanges writes any differences as UPDATE statements. Tracking costs memory and time, so for read-only queries (lists for display, reports) add AsNoTracking().
Relationships, Include and the N+1 problem
public class Order
{
public int Id { get; set; }
public int CustomerId { get; set; } // foreign key
public Customer Customer { get; set; } = null!; // navigation property
public List<OrderItem> Items { get; set; } = new();
}The N+1 problem: you load N orders with one query, then, inside a loop, touch each order's Customer or Items, and the app runs one more query per order: 1 + N queries. With 500 orders that is 501 round trips to the database, and the page becomes slow. (Without lazy loading turned on, the navigation is simply empty instead, which is a different bug.) Fix it by loading what you need up front.
// One query with a JOIN: orders together with their customers and items
var orders = await db.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.Where(o => o.CustomerId == customerId)
.ToListAsync();
// Often better: project only the columns you need
var summary = await db.Orders
.Where(o => o.CustomerId == customerId)
.Select(o => new { o.Id, Customer = o.Customer.Name, ItemCount = o.Items.Count })
.ToListAsync();Raw SQL, safely
LINQ queries are always parameterised, which protects you from SQL injection. If you must write SQL, use db.Products.FromSql($"SELECT * FROM Products WHERE Category = {category}"): the interpolated value becomes a parameter. Never build SQL by adding user input to a string and passing it to FromSqlRaw.
Common mistakes and fixes
- Forgetting SaveChangesAsync: changes stay in memory and are lost.
- Calling ToList() before Where: loads the whole table into memory (see IQueryable in module 6).
- Injecting DbContext into a singleton: DbContext is not thread-safe and must be scoped.
- SQLite and decimal: SQLite has no real decimal type; EF Core can store decimals there, but comparing or sorting decimal columns in a query is not translated. Fine for learning; real money columns usually live in SQL Server or PostgreSQL, where you also set precision (for example [Precision(18, 2)]).
- Editing the database by hand and not with migrations: the schema and the code drift apart. Change classes, add a migration, apply it.
Need help?
Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.
Try:
Watch the lesson
Entity Framework Core: databases with C# · 1/7
In this lesson you will store data in a real database with Entity Framework Core.
Prefer a video by a teacher? Find free videos on “Entity Framework Core tutorial migrations DbContext ASP.NET Core minimal API” on YouTube ↗
In real life: Low-stock report for a medical store chain
A pharmacy chain's inventory API needs a daily list of medicines with fewer than 20 units left, with the store name, so area managers can restock. One EF Core query with a projection fetches exactly that, in a single round trip.
app.MapGet("/reports/low-stock", async (ShopDb db) =>
await db.StockItems
.AsNoTracking()
.Where(s => s.Quantity < 20)
.OrderBy(s => s.Store.City).ThenBy(s => s.Quantity)
.Select(s => new
{
Store = s.Store.Name,
s.Store.City,
Medicine = s.Medicine.Name,
s.Quantity
})
.ToListAsync());Practice (0/4 done)
You add an entity and change another, but forget SaveChangesAsync. What happens in the database?
What is the N+1 problem?
Which command creates a new migration named AddPhone?
Turn your NotesApi (or a new BooksApi) into a database-backed API using EF Core with SQLite: add the packages, a Book entity (Id, Title, Author, Year), a LibraryDb DbContext, register it with AddDbContext, create and apply the InitialCreate migration, and implement GET all, GET by id, POST, PUT and DELETE with async EF Core calls. Restart the app and confirm data is still there.
Quick quiz
Read more (free)
Related help
Before this, learn
This topic builds on these. If something here feels new, open them first.
Related topics
- Dependency injection and service lifetimesWeb APIs with ASP.NET Core and EF Core · same module
- Routing, model binding, validation and status codesWeb APIs with ASP.NET Core and EF Core · same module
- ORMs: working with databases from codeBackend Development · NoSQL, ORMs and data features
- JPA and Spring Data repositoriesJava Programming (Core to Spring Boot) · Spring Boot basics
- Creating the database: schema, migrations and seed dataFull-Stack Development · Building the backend
- Room databaseKotlin and Android App Development · Saving data and calling APIs
Questions people ask about this
Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.
What does an ORM do?
Answer: Maps objects in code to rows in database tables
Object-Relational Mapper.
Which property becomes the primary key by convention?
Answer: Id or <ClassName>Id
EF Core treats Id or ProductId (for Product) as the key.
When should you use AsNoTracking()?
Answer: For read-only queries
No tracking means less memory and faster reads.
Which query is safe from SQL injection?
Answer: FromSql($"SELECT * FROM Users WHERE Name = {name}")
FromSql with interpolation turns the value into a parameter.
Why should the Migrations folder be committed to Git?
Answer: It is the reviewed history of schema changes that every environment applies
Migrations are code that evolves the schema consistently.
Which ORM comes with the Laravel PHP framework?Related
Answer: Eloquent
Eloquent is Laravel's ORM. Hibernate is Java, SQLAlchemy is Python and Drizzle is TypeScript.
Which is the safe way to change a production database schema?Related
Answer: Reviewed migration scripts with Flyway or Liquibase
Migrations are versioned and reviewed.
What is a database migration?Related
Answer: A numbered file that changes the schema by one step, applied in order
Migrations are small, ordered schema changes kept in the repository, so every database can be built the same way.
From Creating the database: schema, migrations and seed data
Why is :category used in @Query instead of joining strings?Related
Answer: Room binds the value safely, preventing SQL injection
Bound parameters are safe and checked.
From Room database
Pass the quiz (60% or more) or mark this topic as done when you are ready.
Your progress is saved on this device only.
Next steps
- This is the last topic of “Web APIs with ASP.NET Core and EF Core”. The module exam comes next.
- Module 8 exam: Web APIs with ASP.NET Core and EF Core · 14 questions · 25 min, without help. 5 topics of this module still to finish first.
- Then the next module, Testing, tooling and Unity. 43 topics left in the course before the final exam.
← Previous: Configuration, secrets and the middleware pipeline