C# Entity Framework Core Basics

Build an EF Core SQLite model in C#, query with LINQ, save tracked changes deliberately, and understand schema and concurrency boundaries.

Lesson 42 of 43Advanced9 min read

A list in memory disappears when a process exits and cannot enforce the same constraints as a relational database. Entity Framework Core connects C# entity types to a database provider and translates supported LINQ expressions into database queries. It also tracks changes under a DbContext, but nothing is persisted merely because a property's value changed.

This guide requires .NET 10 and EF Core 10. The SQLite provider package and examples use version 10.0.0 as a concrete teaching baseline; use an appropriate supported patch version for a real application. The complete examples keep a SQLite database in memory through one open connection. They demonstrate real relational behavior without requiring an external server or retaining a database file.

A model, provider and context work together

An entity is a class mapped under the model, commonly with a primary key and scalar properties. A DbSet provides an entry point for querying and changing that entity type. The provider determines database-specific translation and capabilities. A query supported by one provider may need different treatment on another.

The DbContext is a unit-of-work boundary, not a global thread-safe database singleton. Keep it short-lived and do not run concurrent operations on the same context. In a web application it is commonly registered as scoped. A context holds tracking state, so retaining it indefinitely can accumulate objects and stale observations. The database remains the source of durable constraints; application validation improves feedback but does not replace unique keys or referential integrity.

Queries execute at a materialization boundary

Composing Where, OrderBy and Select builds a query description. Calling a materializing operation such as ToListAsync asks the provider to execute it. Put filters and projections before materialization so the database can reduce returned data. Loading every row and filtering afterward can be expensive and can expose more data than the operation needs.

Not every C# method can be translated into SQL. Keep provider-translated expressions within supported constructs and inspect failures rather than assuming LINQ-to-Objects behavior. Order explicitly when output order matters, and use bounded result sizes for user-facing lists. Parameters are handled through the query translation pipeline, but dynamically constructed raw SQL still needs its own safe parameterization rules.

Tracking supports updates; snapshots support reading

A tracked entity's original state and later changes let the context determine what to persist when SaveChanges is called. AsNoTracking is appropriate for a read-only result when later updates through that instance are unnecessary. It can reduce tracking overhead, but it does not make the database immutable or establish a transaction-wide stable snapshot.

Do not attach an untrusted request object and mark every property modified without reviewing which fields the caller is allowed to change. Load the authorized record and assign permitted fields deliberately. An EF query finding a row does not establish authorization. Database constraints and concurrency handling remain necessary when another request can change the same record between reading and saving.

Schema setup is not a production migration plan

EnsureCreated is convenient for a disposable demonstration database. It is not a substitute for a migrations workflow on a database whose schema must evolve. Migrations describe model changes under a versioned process; review their generated operations and apply them through an authorized deployment procedure.

Do not mix EnsureCreated and migrations casually against the same persistent database. Adding a property to the C# class does not automatically prove the deployed schema has that column. Separate model tests, migration review and deployment permissions. A production process should also plan backup and rollback behavior for changes that transform data. The in-memory fixture avoids those operational responsibilities only because its lifetime is deliberately disposable.

Transactions and concurrency protect different concerns

For providers supporting transactions, one SaveChanges call normally applies its changes transactionally. Several save calls can require an explicit transaction when they must succeed as one unit. A transaction does not automatically remove every lost-update scenario; isolation and concurrent access still matter.

Optimistic concurrency uses a configured token to detect that a row changed since it was read. SQL Server rowversion support and SQLite token behavior differ, so do not copy a provider-specific example blindly. Handle a concurrency failure according to the feature: refresh, reject or retry under a safe business policy. Tests using EF's nonrelational in-memory provider do not establish that actual SQL constraints and translations work; SQLite or the production provider is more appropriate for those relational checks.

Official references

C# Entity Framework Core Basics Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Query a real in-memory SQLite model

Create a .NET 10 console project and add its provider. These commands restore a real package; the application itself uses only an in-memory database.

Replace Program.cs with this complete code.

The open connection keeps this database alive until disposal. Integer cents keep this introductory SQLite filter straightforward; currency scale and range still require a domain contract.

SHELL
dotnet new console -n EfCatalog -f net10.0
dotnet add EfCatalog/EfCatalog.csproj package Microsoft.EntityFrameworkCore.Sqlite --version 10.0.0
C#
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<Catalog>()
    .UseSqlite(connection).Options;
await using var db = new Catalog(options);
await db.Database.EnsureCreatedAsync();

db.Products.AddRange(
    new Product { Id = 1, Code = "BOLT", PriceCents = 250 },
    new Product { Id = 2, Code = "DRILL", PriceCents = 5000 });
await db.SaveChangesAsync();

var affordable = await db.Products.AsNoTracking()
    .Where(p => p.PriceCents <= 1000)
    .OrderBy(p => p.Id)
    .Select(p => p.Code)
    .ToListAsync();
foreach (string code in affordable)
    Console.WriteLine(code);

public sealed class Product
{
    public int Id { get; set; }
    public string Code { get; set; } = "";
    public int PriceCents { get; set; }
}

public sealed class Catalog : DbContext
{
    public Catalog(DbContextOptions<Catalog> options) : base(options) { }
    public DbSet<Product> Products => Set<Product>();
    protected override void OnModelCreating(ModelBuilder model)
    {
        model.Entity<Product>().HasIndex(p => p.Code).IsUnique();
        model.Entity<Product>().Property(p => p.Code).IsRequired();
    }
}
Output
BOLT

C# Entity Framework Core Basics in Real Projects

Worked exercise: save a tracked change

The task is to create stock, load it as a tracked entity, reserve one unit, save the change and verify through a no-tracking query. Replace the previous program with this complete example in the same provider-equipped project. This single-context demonstration is intentionally not a concurrent inventory algorithm.

C#
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<Warehouse>()
    .UseSqlite(connection).Options;
await using var db = new Warehouse(options);
await db.Database.EnsureCreatedAsync();
db.Items.Add(new StockItem { Id = 1, Quantity = 3 });
await db.SaveChangesAsync();

StockItem item = await db.Items.SingleAsync(x => x.Id == 1);
if (item.Quantity < 1)
    throw new InvalidOperationException("Insufficient stock");
item.Quantity--;
await db.SaveChangesAsync();

int remaining = await db.Items.AsNoTracking()
    .Where(x => x.Id == 1).Select(x => x.Quantity).SingleAsync();
Console.WriteLine($"Remaining: {remaining}");

public sealed class StockItem
{
    public int Id { get; set; }
    public int Quantity { get; set; }
}

public sealed class Warehouse : DbContext
{
    public Warehouse(DbContextOptions<Warehouse> options) : base(options) { }
    public DbSet<StockItem> Items => Set<StockItem>();
}
Output
Remaining: 2

Changing the property alone would not persist the update. A concurrent service needs an appropriate atomic update, concurrency token or transaction strategy and must handle conflict results explicitly.

C# Entity Framework Core Basics FAQ

Is DbContext safe to share between concurrent requests?

No. It is intended as a unit-of-work object and does not support concurrent operations on the same instance. Use appropriate scopes or factories to give independent work its own context. Await an operation before starting another on that context. A singleton context can also retain tracking state and stale values long beyond the intended operation.

Does AsNoTracking mean the data cannot change?

It means the returned entities are not tracked for later updates by that query's context. Other operations can still change the database. It is useful for read-only results, not a promise of immutable or transactionally frozen data. If a stable multi-query view is required, define an appropriate transaction and isolation strategy with the actual database provider.

Why does the example keep its connection open?

A SQLite in-memory database belongs to the lifetime of the relevant connection. Closing it discards that database. The fixture opens one connection, uses it for the context and disposes both after the work. A real persistent application uses a different connection and schema lifecycle. Do not generalize the demonstration's database creation strategy into a production startup routine.

Does one SaveChanges solve concurrency?

It groups that call's changes under the provider's transactional support, but a separate reader can still have changed a row before the save. Use concurrency tokens or atomic database operations when the business rule requires conflict detection. Transactions, isolation and optimistic concurrency address related but distinct risks. Test with the real provider and handle failures rather than assuming a successful local demonstration proves concurrent correctness.

C# Entity Framework Core Basics Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Multiple choicemedium

A read-only catalog endpoint needs only product codes under a price limit. Which query design fits?

C# Entity Framework Core Basics Quiz

2 questions to check what you learned.

  1. When does a tracked property change normally reach the database?

    • When an appropriate SaveChanges operation succeeds.
    • Immediately when its setter runs.
    • Only when the context is garbage-collected.
    • When AsNoTracking is added to the query.
  2. What is EnsureCreated appropriate for in this guide?

    • Replacing all production migration review.
    • Initializing a deliberately disposable demonstration database.
    • Updating every existing persistent schema automatically.
    • Guaranteeing authorization for every queried row.