LearnThatStack Ace your next interview

Entity Framework Core

EF Core 10 on .NET 10. Newer features say so, like (7+). Checked Nov 2026 126 commands

The model

  • An entity is a class mapped to a table, and each instance is one row. A DbSet<T> is where queries and changes for that type start.
  • A DbContext is one short-lived session with the database and your unit of work. It isn't thread-safe, so each web request gets its own.
  • LINQ on IQueryable<T> builds an expression tree that EF Core turns into SQL. Nothing runs until you enumerate. That's deferred execution.
  • The change tracker keeps a snapshot of each tracked entity. SaveChanges finds the differences and writes them in one transaction.
  • Migrations change the schema step by step to match your model. Each one is a C# class, with Up to apply it and Down to revert it.
Your codeLINQ and entitiesDbContextchange trackerDatabasetables and rowsREADLINQ becomes SQLrows snapshot kepttracked entitiesAsNoTracking no snapshotWRITEAdd, Remove, editsSaveChanges one transactionBULKExecuteDelete no tracking
SaveChanges writes only the differences. The red arrow runs at once and skips the tracker.

DbContext & entities

the classes every EF Core app starts from

Code First or Database First

Start from C# classesCode First. Migrations manage the schema.
Start from a databaseDatabase First. Scaffolding generates the classes.

TrapModel First belongs to EF6. EF Core has no visual designer and no EDMX file.

Map classes to tables

attributes, Fluent API and indexes

Fluent API in OnModelCreating

Require a column, cap its length.IsRequired().HasMaxLength(100)
Set its column name.HasColumnName("Cost")
Set a default or a computed value.HasDefaultValue(0).HasComputedColumnSql("...")
Make a composite keyHasKey(e => new { e.A, e.B })

TipUse it for what has no annotation, like sequences and computed columns. It overrides annotations, which override conventions.

Indexes

Add an indexHasIndex(p => p.Name)[Index(nameof(Name))]
Index several columnsHasIndex(p => new { p.A, p.B })
Make it unique or filtered.IsUnique().HasFilter("[Email] IS NOT NULL")

TrapColumn order matters. The database can use a composite index for filters on its leading column.

Converters and shadow properties

Store an enum as its name.HasConversion<string>()
Convert both ways with lambdas.HasConversion(toDb, fromDb)
Add a column with no propertyProperty<DateTime>("x")
Query that columnEF.Property<DateTime>(p, "x")

TipA shadow property is in the model and the table, but not on the class. It suits audit columns.

TrapA converted list needs a ValueComparer. Without one, EF Core compares references and never saves a tag you add.

Relationships

foreign keys and navigations

Configure one-to-many

modelBuilder.Entity<Post>() .HasOne(p => p.Blog) .WithMany(b => b.Posts) .HasForeignKey(p => p.BlogId);
Use an attribute instead[ForeignKey("Blog")]

TipPost holds the foreign key, so it's the dependent. BlogId matches by convention, so this setup is optional.

Many-to-many and self-reference

Make a join table (5+)Give each side a collection. These are skip navigations.
Store extra data on the join.UsingEntity<StudentCourse>(...)
Allow a row with no parentint? ManagerId
Stop a cascade down a tree.OnDelete(DeleteBehavior.Restrict)

TrapA join class does nothing on its own. Map the skip navigations through it with UsingEntity<T>.

Migrations & seed data

change the schema and keep its history in source control

Create and apply migrations

Add a migrationdotnet ef migrations add <name>
Apply what's pendingdotnet ef database update
Go back, or revert them alldotnet ef database update <name>dotnet ef database update 0
Remove the last migrationdotnet ef migrations remove
Get a SQL script to reviewdotnet ef migrations script

Tipadd compares your model with the last snapshot. update logs each run in __EFMigrationsHistory.

TrapDon't deploy with database update or at app startup. Use a bundle or a reviewed SQL script.

Edit a generated migration

Rename and keep the datamigrationBuilder.RenameColumn(...)
Add a view or triggermigrationBuilder.Sql("...")

TipHasData rows flow into the next migration you add.

TrapEF Core usually can't tell a rename from a drop plus an add. So it drops the column and loses the data.

Fix a team migration conflict

Abort the merge, then remove your migrationgit merge --abortdotnet ef migrations remove
Merge theirs, then add yours againdotnet ef migrations add <name>

TrapUp to EF Core 10, Git can merge the snapshot with no conflict, so nobody notices. From EF Core 11, a diverged history always conflicts.

Scaffold an existing database

Generate the context and classesdotnet ef dbcontext scaffold "<conn>" <provider>
Keep the connection string out of the code--no-onconfiguring
Overwrite an earlier run--force

TipScaffold-DbContext does the same in the Package Manager Console. The classes are partial, so keep your code in separate files.

Query & load data

turn LINQ into SQL and load related rows

Paging

Jump to any page (offset)Skip(n).Take(size)The database still reads every skipped row.
Keep deep pages fast (keyset)Where(p => p.Id > lastId).Take(size)It can't jump to a page number.

TrapAlways order by a unique key, or rows can repeat or go missing between pages.

Raw SQL

Query with parametersFromSql($"... {price}")
Call a stored procedureFromSqlRaw("EXEC Proc {0}", id)
Run an update or deleteExecuteSql($"...")

TipThe SQL must return every column of the entity. For scalars, use Database.SqlQuery<T>.

TrapFromSqlRaw is open to SQL injection if you concatenate user input into the string.

Track & save changes

what SaveChanges writes, and how to control it

Change tracker states

DetachedAddedUnchangedModifiedDeleted
entity statesAdd gives Added, a query gives Unchanged. An edit gives Modified, Remove gives Deleted.
Read or set a stateEntry(e).State
List tracked entitiesChangeTracker.Entries()
Check one propertyProperty(p => p.Name).IsModified
Read old and new values.OriginalValue.CurrentValue

TipDetached means not tracked. SaveChanges calls DetectChanges, which compares values with the snapshot.

Read without tracking

Skip tracking on one queryAsNoTracking()
Make it the defaultQueryTrackingBehavior.NoTracking

TipA projection with no entity types is never tracked. AsTracking() opts one query back in.

TrapSaveChanges ignores changes to these entities until you attach them again. A row that appears twice becomes two objects.

Update() or edit a property

Save a disconnected entityUpdate(entity)
Change one column without loading itAttach(e).IsModified = true

TipEditing a tracked entity updates only the columns you changed.

TrapUpdate() writes every column, so it can overwrite a value another user changed in a column you never touched.

Concurrency and locking

Check one column[ConcurrencyCheck]
Protect a whole row[Timestamp].IsRowVersion()
Read the current row, retryGetDatabaseValuesAsync()
Lock the row as you readWITH (UPDLOCK, ROWLOCK)Pessimistic. EF Core has no API, so use raw SQL.

TipOptimistic locking adds the token to WHERE. If no row matches, SaveChanges throws DbUpdateConcurrencyException. With no token, the last write wins.

TrapUPDLOCK lasts only until the transaction ends. Read and save in one explicit transaction, and keep it short.

Transactions

Group several savesBeginTransactionAsync()CommitAsync()
Span several contextsnew TransactionScope(...)

TipEach SaveChanges is already one transaction.

TrapA scope across two databases becomes a distributed transaction. .NET supports those only on Windows, from .NET 7.

Make queries faster

fewer rows, fewer columns, fewer round trips

Common slowdowns

Fix N+1 queriesInclude(...)
Select only what you needSelect(p => p.Name)
Fix a Where that can't translateEF.Functions.Like(...)
Compile a hot queryEF.CompileQuery(...)

TipA compiled query skips the cache lookup. Parameters must be scalars, and the saving is often small, so measure first.

TrapToList() before Select runs the projection in memory, after every column has loaded.

Split queries (5+)

PostsTags100 rows
single queryThe join returns every combination of 10 posts and 10 tags.
PostsTags10 rows10 rows
split queryOne query per collection returns 20 rows, plus one for the blog.
Split one query, or switch backAsSplitQuery()AsSingleQuery()
Split by defaultUseQuerySplittingBehavior(...)

TrapEach query is a round trip. A concurrent change can leave results inconsistent unless you use a serializable or snapshot transaction.

EF Core or Dapper

Pick a default for a business appEF Core. It gives you LINQ, change tracking and migrations.
Write the SQL by handDapper, a micro-ORM. It maps rows to objects, with no tracking or migrations.

TipMany apps use EF Core for writes and Dapper for heavy reads. Profile before you switch.

Soft delete & multi-tenancy

rules EF Core adds to every query

Query filters and soft delete

Filter every queryHasQueryFilter(p => !p.IsDeleted)
Skip filters on one queryIgnoreQueryFilters()
Turn Remove into an updateentry.State = EntityState.ModifiedOverride SaveChanges and SaveChangesAsync.
Keep a unique index usable.HasFilter("[IsDeleted] = 0")

TipDatabase cascades never fire, so mark the children yourself.

TrapA second HasQueryFilter on a type replaces the first. Use one lambda, or named filters (10+).

Multi-tenancy

Get the strongest isolationOne database per tenant.
Keep the cost lowestOne shared table with a tenant column and a query filter.

TipThe filter covers only queries, so set TenantId on new rows yourself.

TrapRead the tenant from a context field. EF Core caches the model, so a copied value leaks the first tenant's rows to everyone.

Advanced table mapping

inheritance, owned types, views and history

Map an inheritance hierarchy

HasDiscriminator<string>("Type")Share one table, the default (TPH)
ToTable("Dogs")Give every type its own table (TPT)
UseTpcMappingStrategy()Give each concrete type a table (TPC)
not neededthe cost of this mapping

TipStart with TPH. Choose TPC when you mostly query concrete types.

TrapTPC (7+) keys must be unique across tables, so SQL Server uses a shared sequence.

Owned, split and keyless types

Keep a value object in its owner's tableOwnsOne(c => c.Address)
Give a collection its own tableOwnsMany(o => o.Items)
Map two entities to one tableToTable("Blogs")They share one primary key.
Map a view with no keyHasNoKey().ToView("V")

TipThe docs suggest complex types for value objects. They compare by value and have no hidden key.

TrapEF Core never tracks a keyless type, so SaveChanges can't write it. It also can't be the principal of a relationship.

Production, design & tests

common errors, settings, structure and test choices

Fix "Cannot access a disposed object"

Run queries before it closesToList()Don't return IQueryable from a using block.
Give background work a scopeIServiceScopeFactoryIDbContextFactory<T>

TrapAlso look for Dispose on an injected context, an unawaited task, or a singleton holding the context.

Found this useful? Pass it on.
Pro · $10/mo

This sheet stays free. With Pro, you can:

  • Every answer on this sheet. Read the full answer to all 54 questions it links, not just the 7 free ones.
  • My sheet. The cards you star and the ones you miss in Quiz me, from every sheet, on one page to quiz and print.
  • PDFs. Download this sheet and My sheet, laid out for A4, with every question a link to its answer.
  • The rest of Pro. Every answer in the question library, and 1,000 AI credits a month.