Skip to content

Entity Framework Core Cheat Sheet

An EF Core 10 / .NET 10 cheat sheet covering CRUD, LINQ, relationships, migrations, and concurrency with SQLite examples.


Entity Framework is an ORM that maps .NET objects to database data. This page covers EF Core. EF6 is a separate product line; migration requires more than replacing a package.

Topic EF Core EF6
Main namespace Microsoft.EntityFrameworkCore System.Data.Entity
Main packages Microsoft.EntityFrameworkCore.* EntityFramework
Model definition Code and configuration; reverse engineering from an existing DB Code First, EDMX designer, and other workflows
Coverage here EF Core 10 / .NET 10 Not covered by the API examples

EF Core 10 requires .NET 10. EF Core 8 and 9 target .NET 8. For an existing application, check whether its database provider supports the intended EF Core major version.

Official EF6 and EF Core comparison / Release information


Create a console project using SQLite. These commands select stable version 10.0.12 and align the Microsoft EF package and tool versions.

Terminal window
dotnet new console -n EfCheatSheet --framework net10.0
cd EfCheatSheet
dotnet package add Microsoft.EntityFrameworkCore.Sqlite --version 10.0.12
dotnet package add Microsoft.EntityFrameworkCore.Design --version 10.0.12
dotnet new tool-manifest
dotnet tool install dotnet-ef --version 10.0.12
dotnet ef --version

If the repository already has a tool manifest, use dotnet tool restore instead of creating another. All subsequent file names and commands are relative to this project directory.


Create BlogContext.cs. DbContext coordinates queries and change tracking; DbSet<T> is an entry point for querying and adding entities.

using Microsoft.EntityFrameworkCore;
public sealed class Blog
{
public int Id { get; set; }
public string Name { get; set; } = "";
public List<Post> Posts { get; set; } = [];
}
public sealed class Post
{
public int Id { get; set; }
public int BlogId { get; set; }
public Blog Blog { get; set; } = null!;
public string Title { get; set; } = "";
public int Views { get; set; }
public bool IsDeleted { get; set; }
public Guid Version { get; set; } = Guid.NewGuid();
}
public sealed class BlogContext(DbContextOptions<BlogContext> options) : DbContext(options)
{
public DbSet<Blog> Blogs => Set<Blog>();
public DbSet<Post> Posts => Set<Post>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>(entity =>
{
entity.Property(b => b.Name).HasMaxLength(100).IsRequired();
entity.HasIndex(b => b.Name).IsUnique();
});
modelBuilder.Entity<Post>(entity =>
{
entity.Property(p => p.Title).HasMaxLength(200).IsRequired();
entity.Property(p => p.Version).IsConcurrencyToken();
entity.HasIndex(p => new { p.BlogId, p.Id });
entity.HasOne(p => p.Blog)
.WithMany(b => b.Posts)
.HasForeignKey(p => p.BlogId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasQueryFilter("SoftDelete", p => !p.IsDeleted);
});
}
}

By convention, Id is the primary key. BlogId is a foreign key; Blog and Posts are navigations. null! tells the compiler to expect EF to supply a value; it does not load related data automatically.

Settings such as HasMaxLength describe the database model, not EF input validation. SQLite does not enforce the string-length limit, so validate input separately. Named query filters require EF Core 10.

Official modeling guide


Model configuration can be placed directly on entity classes and properties. For conflicting settings, Fluent API overrides attributes, and attributes override conventions. Attributes keep simple mappings near the model; Fluent API supports more detailed configuration and separates persistence concerns from entity classes.

Attribute Purpose Fluent API counterpart
[Table("Articles")] Table name ToTable("Articles")
[Column("title")] Column name HasColumnName("title")
[Key] Single-property primary key HasKey(e => e.Id)
[PrimaryKey(nameof(A), nameof(B))] Composite key on the class (EF Core 7+) HasKey(e => new { e.A, e.B })
[Required] Required / non-nullable model property IsRequired()
[MaxLength(200)] Maximum length HasMaxLength(200)
[StringLength(200)] Maximum length; MinimumLength is not a DB constraint HasMaxLength(200)
[Precision(18, 2)] Numeric precision and scale HasPrecision(18, 2)
[Unicode(false)] Non-Unicode configuration (provider-dependent) IsUnicode(false)
[Index(nameof(Code), IsUnique = true)] Unique index on the class HasIndex(e => e.Code).IsUnique()
[ForeignKey(nameof(BlogId))] Foreign key for a navigation HasForeignKey(e => e.BlogId)
[InverseProperty(nameof(Blog.Posts))] Identify the inverse navigation WithMany(b => b.Posts)
[NotMapped] Exclude from mapping Ignore(e => e.DisplayName)
[DatabaseGenerated(DatabaseGeneratedOption.Identity)] Value generated on add ValueGeneratedOnAdd()
[DatabaseGenerated(DatabaseGeneratedOption.Computed)] Value generated on add and update ValueGeneratedOnAddOrUpdate()
[ConcurrencyCheck] Include the original value in update/delete predicates IsConcurrencyToken()
[Timestamp] Database generation on add/update and a concurrency token IsRowVersion()

Key, Required, MaxLength, StringLength, ConcurrencyCheck, and Timestamp are in System.ComponentModel.DataAnnotations. Table, Column, ForeignKey, InverseProperty, NotMapped, and DatabaseGenerated are in its .Schema namespace. EF Core’s PrimaryKey, Index, Precision, and Unicode are in Microsoft.EntityFrameworkCore; do not confuse these with similarly named EF6 APIs.

This is a separate entity declaration, not an operation to paste at the end of Program.cs. To use it, register DbSet<AnnotatedArticle> in a context and update its schema. It does not replace the shared Blog / Post model.

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
[Table("Articles")]
[Index(nameof(Code), IsUnique = true)]
public sealed class AnnotatedArticle
{
[Key]
public int Id { get; set; }
[Required, MaxLength(32)]
public string Code { get; set; } = "";
[Column("title")]
[Required, MaxLength(200)]
public string Title { get; set; } = "";
[Precision(18, 2)]
public decimal Price { get; set; }
[ConcurrencyCheck]
public Guid Version { get; set; } = Guid.NewGuid();
[NotMapped]
public string DisplayName => $"{Code}: {Title}";
}

[ConcurrencyCheck] does not generate a new value: assign a new Version on each protected update, just as in the shared model. [DatabaseGenerated] describes value generation; it does not by itself define a computed SQL expression or make every type auto-generated. Use provider-supported defaults or computed-column configuration as appropriate.

EF Core does not run Data Annotations object validation automatically during SaveChanges. For example, [Range] and [EmailAddress] do not create database constraints. Validate input through ASP.NET Core validation or Validator.TryValidateObject as appropriate. With nullable reference types enabled, a string property is required by convention; [Required] makes that mapping explicit. C# required is an initialization rule, not the same feature. Database enforcement of length and precision remains provider-dependent.

Model configuration / Property mapping / Relationship attributes


Replace Program.cs with this code. Keeping the connection open lets contexts share the in-memory SQLite database.

using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<BlogContext>()
.UseSqlite(connection)
.Options;
await using var db = new BlogContext(options);
await db.Database.EnsureCreatedAsync();
var blog = new Blog
{
Name = "Development",
Posts = [new Post { Title = "Hello EF Core", Views = 10 }]
};
db.Blogs.Add(blog);
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
// Insert an operation example from a later section here.
Console.WriteLine(await db.Posts.CountAsync());

Run dotnet run; it prints 1. Insert one later “operation example” at the end of this program, using the existing db, options, and blog. Complete-file examples are identified separately. Every run creates a fresh database, so examples do not depend on earlier operations.

EnsureCreatedAsync is useful for learning and temporary databases. It neither creates migration history nor updates an existing schema. Do not mix it with migrations on the same database.


Operation example. For a tracked update, change the retrieved entity’s properties and call SaveChangesAsync.

var post = new Post { BlogId = blog.Id, Title = "Second post" };
db.Posts.Add(post);
await db.SaveChangesAsync();
var found = await db.Posts.SingleAsync(p => p.Id == post.Id);
Console.WriteLine(found.Title);
found.Title = "Updated post";
found.Version = Guid.NewGuid();
await db.SaveChangesAsync();
db.Posts.Remove(found);
await db.SaveChangesAsync();
Console.WriteLine(await db.Posts.CountAsync());

Add, Update, and Remove normally change tracking state; SQL runs at SaveChanges. Generated keys do not automatically require AddAsync. Update can affect the state of an entire graph, so map only permitted fields rather than passing API input directly.


Operation example. Compose expressions that EF translates to SQL, then execute them with methods such as ToListAsync. Use Select to retrieve only the fields you need.

var query = db.Posts
.AsNoTracking()
.Where(p => p.Views >= 5)
.OrderByDescending(p => p.Views)
.ThenBy(p => p.Id)
.Select(p => new { p.Id, p.Title, BlogName = p.Blog.Name });
var rows = await query.ToListAsync();
Console.WriteLine(rows[0].BlogName);
Console.WriteLine(await db.Posts.AnyAsync(p => p.Views > 0));
Console.WriteLine(await db.Posts.SumAsync(p => p.Views));
Console.WriteLine(query.ToQueryString());

Compose filters while the query is still IQueryable<T>. Operations after ToList or AsEnumerable run in memory. Arbitrary C# methods are not necessarily translatable; untranslatable filters normally throw at execution.


Choose a method that matches your requirements.

API Behavior
FindAsync(key) Looks for a tracked primary key first, then queries the DB
FirstOrDefaultAsync() Returns the first match or the default value
SingleAsync() Requires exactly one match; throws for zero or multiple matches
SingleOrDefaultAsync() Returns the default for zero matches; throws for multiple
AnyAsync() Checks existence
CountAsync() Returns a count
ToListAsync() Materializes results in memory

Use OrderBy to define “first.” FindAsync may return an already tracked entity, so it does not always retrieve fresh database state.


Operation example. Use AsNoTracking for read-only queries. Changes to an untracked entity are not saved automatically.

var detached = await db.Posts.AsNoTracking().SingleAsync();
detached.Title = "Not saved";
Console.WriteLine(db.Entry(detached).State);
Console.WriteLine(await db.SaveChangesAsync());
var tracked = await db.Posts.SingleAsync();
tracked.Title = "Saved";
tracked.Version = Guid.NewGuid();
await db.SaveChangesAsync();

A context generally tracks only one instance per primary key. AsNoTrackingWithIdentityResolution reuses instances for repeated keys within the result, without tracking them for updates in the context.


Operation example. Include loads related entities. If a projection only needs related values, reference them in Select; no Include is needed.

var blogs = await db.Blogs
.AsNoTracking()
.Include(b => b.Posts.Where(p => p.Views >= 5))
.AsSplitQuery()
.ToListAsync();
Console.WriteLine(blogs[0].Posts.Count);
var one = await db.Blogs.SingleAsync();
await db.Entry(one).Collection(b => b.Posts).LoadAsync();
Console.WriteLine(one.Posts.Count);

Use ThenInclude for additional relationship levels. AsSplitQuery loads collections with separate queries to reduce JOIN row multiplication, but adds round trips and may observe inconsistent data during concurrent updates.

With tracking, filtered Include may contain related entities tracked earlier, even if they fail the filter. Lazy loading requires additional configuration and may cause N+1 queries when navigations are accessed in a loop.

Official eager-loading guide


Operation example. Offset pagination suits page-number navigation; keyset pagination suits moving to the next page. Use a unique ordering.

int pageSize = 20;
int pageNumber = 1;
var page = await db.Posts.AsNoTracking()
.OrderBy(p => p.Id)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
int lastId = 0;
var next = await db.Posts.AsNoTracking()
.Where(p => p.Id > lastId)
.OrderBy(p => p.Id)
.Take(pageSize)
.ToListAsync();
Console.WriteLine($"{page.Count}, {next.Count}");

For the actual next page, supply the previous page’s last Id. If ordering by multiple columns, include all ordering columns in the cursor predicate. Cap page size and consider indexes matching the filter and ordering.


Operation example. ExecuteUpdateAsync and ExecuteDeleteAsync are relational APIs introduced in EF Core 7. They execute SQL directly without loading entities.

int updated = await db.Posts.Where(p => p.Views < 100)
.ExecuteUpdateAsync(setters => setters.SetProperty(p => p.Views, p => p.Views + 1));
int deleted = await db.Posts.Where(p => p.Views == 0).ExecuteDeleteAsync();
Console.WriteLine($"{updated}, {deleted}");

No SaveChanges call is needed. The change tracker is not synchronized; reload or otherwise reconcile state before mixing with tracked updates of the same rows. Calls are separate operations and do not automatically share one transaction. Concurrency tokens are neither checked nor updated automatically.

Official ExecuteUpdate / ExecuteDelete guide


Operation example. When supported by the provider, one SaveChanges call runs in a transaction by default. Use an explicit transaction to group multiple saves or direct SQL operations.

await using var transaction = await db.Database.BeginTransactionAsync();
try
{
db.Blogs.Add(new Blog { Name = "Transactions" });
await db.SaveChangesAsync();
await db.Posts.ExecuteUpdateAsync(s => s.SetProperty(p => p.Views, p => p.Views + 1));
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}

Rolling back does not restore tracked objects’ values. Recreate the context where appropriate. With a retrying execution strategy, the entire explicit transaction must be coordinated as one retriable unit.


Operation example. The shared model’s Version detects a preceding update by another context. SQLite has no SQL Server-style rowversion, so this example updates the token in application code.

await using var other = new BlogContext(options);
var mine = await db.Posts.SingleAsync();
var theirs = await other.Posts.SingleAsync();
theirs.Title = "Changed elsewhere";
theirs.Version = Guid.NewGuid();
await other.SaveChangesAsync();
mine.Title = "My change";
mine.Version = Guid.NewGuid();
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
await db.Entry(mine).ReloadAsync();
Console.WriteLine(mine.Title);
}

This example discards the local edit and reloads. In an application, choose a policy such as notifying the user, merging values, or conditionally retrying. Update the token on every write path it is intended to protect.

Official concurrency guide

SQL Server’s rowversion is an automatically generated 8-byte binary value used for optimistic concurrency. Despite the attribute name [Timestamp], it is not a date or time. RowVersion is simply a property name; [Timestamp] or .IsRowVersion() supplies the configuration.

The following declarations are for SQL Server, separate from the SQLite sample. Add Microsoft.EntityFrameworkCore.SqlServer 10.0.12, construct the context options with UseSqlServer(connectionString), and create the SQL Server schema with migrations. Obtain the connection string from configuration.

using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
public sealed class SqlServerDocument
{
public int Id { get; set; }
[MaxLength(200)]
public string Title { get; set; } = "";
[Timestamp]
public byte[] RowVersion { get; set; } = [];
}
public sealed class SqlServerContext(DbContextOptions<SqlServerContext> options)
: DbContext(options)
{
public DbSet<SqlServerDocument> Documents => Set<SqlServerDocument>();
}

SQL Server generates a new token on insert and on each update of the row, even if an update writes the same values. EF uses the original token along with the key in tracked update/delete predicates. If another writer changed or deleted the row, zero affected rows result in DbUpdateConcurrencyException. After a successful save, EF reads back the generated token; do not assign a new token yourself.

Choice Value generation Typical use
[Timestamp] / IsRowVersion() Database-generated SQL Server byte[] / rowversion
[ConcurrencyCheck] / IsConcurrencyToken() Not configured by this setting Application-managed Guid or other property

SQLite does not gain automatic rowversion generation from [Timestamp]; use the application-managed token shown earlier. ExecuteUpdate and ExecuteDelete bypass EF’s automatic concurrency handling. For these APIs, explicitly filter on the original token and check the affected-row count.

Round-trip the token from when the user opened the record, for example as Base64, and restore it to byte[]. If you only reload the latest row when saving, you can miss changes made while the user was editing. The following helper belongs in its own file alongside the SQL Server declarations above.

using Microsoft.EntityFrameworkCore;
public static class DocumentUpdates
{
public static async Task<byte[]> UpdateTitleAsync(
SqlServerContext db, int id, string title, byte[] originalRowVersion,
CancellationToken cancellationToken = default)
{
var document = await db.Documents.SingleAsync(d => d.Id == id, cancellationToken);
db.Entry(document).Property(d => d.RowVersion).OriginalValue = originalRowVersion;
document.Title = title;
await db.SaveChangesAsync(cancellationToken);
return document.RowVersion;
}
}

This helper returns the new token after saving. The caller must validate the request and enforce authorization, distinguish a missing row from a concurrency conflict, and handle DbUpdateConcurrencyException with a policy such as HTTP 409 or asking the user to reload. With no detected property change, SaveChanges sends no UPDATE and therefore performs no concurrency check. After a conflict, resolve or discard the context rather than blindly retrying.

EF Core concurrency / SQL Server rowversion


Operation example. The shared model’s named filter excludes posts whose IsDeleted is true from ordinary queries.

var post = await db.Posts.SingleAsync();
post.IsDeleted = true;
post.Version = Guid.NewGuid();
await db.SaveChangesAsync();
Console.WriteLine(await db.Posts.CountAsync());
var all = await db.Posts.IgnoreQueryFilters(["SoftDelete"]).ToListAsync();
Console.WriteLine(all.Count);

Filters do not turn Remove into a soft delete; you must set the deletion flag. Parameterless IgnoreQueryFilters() disables all filters. Do not treat filters alone as an authorization boundary.

Official global query filters guide


Operation example. Passing an interpolated string directly to FromSql sends the interpolated values as parameters.

string title = "Hello EF Core";
var posts = await db.Posts
.FromSql($"SELECT * FROM Posts WHERE Title = {title}")
.AsNoTracking()
.ToListAsync();
Console.WriteLine(posts.Count);

Do not concatenate external input into FromSqlRaw. Identifiers such as column names cannot be parameterized; choose dynamic identifiers from an allowlist. SQL returning an entity must provide its mapped columns.

Official SQL query guide


The following workflow uses a separate file database, blog.db. Add BlogContextFactory.cs. A design-time factory lets the tools create a context without running the learning-oriented Program.cs.

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
public sealed class BlogContextFactory : IDesignTimeDbContextFactory<BlogContext>
{
public BlogContext CreateDbContext(string[] args)
{
var options = new DbContextOptionsBuilder<BlogContext>()
.UseSqlite("Data Source=blog.db")
.Options;
return new BlogContext(options);
}
}

If the application should use blog.db, configure that same database at runtime. The learning-oriented Program.cs above continues to use an in-memory DB. Obtain credentials from configuration or secret storage instead of embedding them in source.

Run these commands in the project directory. database update modifies the learning database selected by the factory above.

Terminal window
dotnet ef migrations add InitialCreate --context BlogContext
dotnet ef migrations list --context BlogContext
dotnet ef migrations script 0 InitialCreate --output migration.sql --context BlogContext
dotnet ef database update --context BlogContext
dotnet ef migrations has-pending-model-changes --context BlogContext

After changing the model, add another migration with a descriptive name. Review generated code and SQL before applying it. Use dotnet ef migrations remove --context BlogContext to remove the last unapplied migration; do not merely delete files for an applied migration.

Apply reviewed SQL or migration bundles as part of production deployment. SQLite does not support --idempotent scripts; specify the start and end migration as shown above.

Official migration deployment guide / SQLite limitations


This example generates a model from the migrated blog.db into a separate output directory.

Terminal window
dotnet ef dbcontext scaffold "Data Source=blog.db" Microsoft.EntityFrameworkCore.Sqlite --output-dir Scaffolded --context ReverseContext --no-onconfiguring

Review generated code before use. Reverse engineering recovers schema information, not intent such as query filters or application-managed concurrency tokens. With --no-onconfiguring, supply connection configuration at runtime.


Use a DbContext for a short unit of work and dispose it afterward. Do not share an instance across threads or overlapping asynchronous operations.

  • ASP.NET Core’s AddDbContext registers a scoped service by default, commonly used per request.
  • Consider IDbContextFactory<T> when DI scope and unit of work differ, such as background jobs. The caller must dispose contexts it creates.
  • Do not run queries on the same context concurrently with Task.WhenAll. Use separate contexts for parallel operations.
  • Pass cancellation tokens to APIs such as ToListAsync(cancellationToken) and SaveChangesAsync(cancellationToken). Cancellation support also depends on the provider.
  • SQLite’s async APIs execute synchronously because of underlying limitations, so this example does not establish async performance on other databases.

Official DbContext configuration and lifetime guide / SQLite async limitations


Operation example. ToQueryString inspects SQL without executing the query. Also inspect execution logs and database query plans to understand actual SQL and timings.

var query = db.Posts
.TagWith("Posts list")
.AsNoTracking()
.Where(p => p.BlogId == blog.Id)
.OrderBy(p => p.Id)
.Take(20);
Console.WriteLine(query.ToQueryString());
  • Project only needed columns and avoid unbounded result sets.
  • Check for N+1 queries, unnecessary Includes, large JOIN results, and deep offset pagination.
  • EnableSensitiveDataLogging includes values in logs. Restrict it to development scenarios that need it.
  • Use execution plans to evaluate indexes; API names alone do not determine performance.

Operation example. Read back through a separate context after saving so the check is not limited to tracked objects.

db.Posts.Add(new Post { BlogId = blog.Id, Title = "Persistence test" });
await db.SaveChangesAsync();
await using var verification = new BlogContext(options);
var saved = await verification.Posts.AsNoTracking()
.SingleAsync(p => p.Title == "Persistence test");
if (saved.Id <= 0 || saved.BlogId != blog.Id)
throw new InvalidOperationException("Persistence check failed.");
Console.WriteLine("Persistence check passed.");

This minimal executable check signals failure by throwing. In a test project, replace it with assertions from a framework such as MSTest.

In-memory SQLite can exercise relational constraints and some SQL behavior, but does not replace production SQL Server or PostgreSQL. Test important queries, collations, transactions, and migrations against the production database engine too. EF Core’s InMemory provider does not reproduce relational database behavior.

Official testing strategy guide


The examples on this page assume the EF Core 10 SQLite provider.

Feature or task Notes
Set-based updates and deletes ExecuteUpdate / ExecuteDelete require EF Core 7 or later
Named query filters Require EF Core 10; older versions combine conditions with && in one filter
SQL Server concurrency Can use database-generated rowversion; do not reuse that configuration for SQLite
String lengths and numeric precision Constraints, types, and translatable operations vary by database
SQLite schema changes Some operations rebuild tables; schemas and sequences are unsupported
Providers SQL Server uses UseSqlServer, SQLite uses UseSqlite, and Npgsql uses UseNpgsql; each needs its provider package

Check breaking changes and provider compatibility before major upgrades. These examples do not establish query translation or behavior for other providers.

What’s new in EF Core 10 / Provider list


Official EF Core documentation