EF Core 10 Relational Data · Lesson 2

Keys, entity states and change detection

Observe Added, Unchanged, Modified and Detached on a real tracked entity.

External net10.0 console; SDK10.0.403; Microsoft.EntityFrameworkCore.Sqlite 10.0.14; native SQLite relational provider. Portable practice is a separate .NET9 BCL-only program.

Read or download the complete SDK project, then follow its setup on your computer. The separate C# companion practice below runs in SharpRune’s .NET 9 console environment.

Separate entity identity from an object’s current state

A key identifies which entity row a context is tracking; it is not a timestamp or a universal object-memory identity. The context also records whether an entity should be inserted, updated, deleted or left unchanged. This lesson observes those intentions through the genuine ChangeTracker rather than assigning familiar state labels to a custom object. The generated positive key is checked after saving, while the item’s name is changed independently. Keeping identity and mutable values separate helps explain why two disconnected objects with the same key cannot simply become two independently tracked versions of one row in a single context.

Observe transitions at deliberate detection boundaries

Adding the item establishes Added before any database write. SaveChangesAsync persists it and accepts the tracked state as Unchanged. Changing Name is followed by an explicit DetectChanges call so the next observation is deliberately Modified rather than dependent on an undocumented assumption about when detection happened. After another save, Clear detaches tracked entities; the subsequent Entry state is Detached. A separate context then reads revised from SQLite. The state transcript therefore describes context intentions while the fresh materialization establishes storage. Clear neither sends a delete nor reverses the update that already committed.

using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<LabDb>().UseSqlite(connection).Options;
await using (var db = new LabDb(options))
{
    await db.Database.EnsureCreatedAsync(); var item = new Item { Name = "rune" }; db.Items.Add(item);
    Check(db.Entry(item).State == EntityState.Added, "added"); Console.WriteLine(db.Entry(item).State);
    await db.SaveChangesAsync(); Check(item.Id > 0 && db.Entry(item).State == EntityState.Unchanged, "saved identity"); Console.WriteLine(db.Entry(item).State);
    item.Name = "revised"; db.ChangeTracker.DetectChanges(); Check(db.Entry(item).State == EntityState.Modified, "detected edit"); Console.WriteLine(db.Entry(item).State);
    await db.SaveChangesAsync(); db.ChangeTracker.Clear(); Check(db.Entry(item).State == EntityState.Detached, "detached"); Console.WriteLine(db.Entry(item).State);
}
await using (var verify = new LabDb(options)) Check((await verify.Items.SingleAsync()).Name == "revised", "persisted rename");

static void Check(bool condition, string name) { if (!condition) throw new InvalidOperationException("CHECK: " + name); }

Use a value comparison without presenting it as tracking

The portable Edit record begins with equal Original and Current strings, so Changed is False. A with expression creates another record whose Current is revised, making ordinal comparison return True. This is a deterministic change predicate on supplied values. It has no identity map, original database snapshot, relationship fixup or generated key. The comparison remains useful when designing a user-facing edit model, but it cannot prove that EF detects an entity property, decides an UPDATE is required or affects one stored row. Those behaviors are established only by the external context and provider assertions.

Detaching is not undo, and inspection can trigger work

Do not use ChangeTracker.Clear as a database rollback operation. It ends tracking for those objects, leaving both the saved row and the ordinary detached C# instance in existence. Likewise, observing state through APIs that perform automatic change detection is different from proving that every assignment immediately changed EF’s internal state. This lesson calls DetectChanges explicitly to make the observation controlled. Disconnected updates require deciding which fields are allowed to change and what original concurrency evidence is available. Blindly marking an entire client-supplied graph Modified can overwrite more data than the intended edit authorized.

Practice: inspect a detached object and fresh storage

After clearing the tracker, change the detached object’s Name again and call SaveChangesAsync. Predict that no tracked update is pending, then verify the stored name from another context. In a separate fresh case, attach a known existing object and explicitly mark one property modified, explaining why its key identifies an existing row. Do not mix those experiments into one unexplained sequence. Record state before and after detection, the save result and the fresh stored value so a reader can distinguish object mutation, tracking intention and actual database change.

  • Hint: detached object changes remain ordinary C# changes until an update strategy adopts them.
  • Hint: limit disconnected updates to fields the operation actually owns.

Practice: make the edit predicate’s normalization policy explicit

Test Original and Current with different case, surrounding spaces and empty values. Decide whether the editor preserves those differences or normalizes them before comparison. Keep that decision separate from a database collation: ordinal string equality is this small model’s stated rule, not proof of how a provider compares keys. Add a validation result for an unacceptable empty name without conflating invalid input with unchanged input. Independent expected results should explain each case before implementation, so another correct edit-model representation could satisfy the same contract without copying EF’s state enum.

  • Hint: unchanged and invalid are different outcomes.
  • Hint: application normalization does not create a relational uniqueness or concurrency rule.

Review intentions and effects as different observations

The four state lines are Added, Unchanged, Modified and Detached, with a positive generated key and a fresh persisted-name assertion supporting the real EF workflow. The separate two-line comparison lab remains BCL-only. This distinction avoids equating a state label with proof that storage accepted a write or that another writer cannot change it later. When reviewing production code, identify who owns original values, when detection occurs, which fields are persisted and how failures end the unit of work. A clear state observation is useful evidence, but it does not replace constraints, transactions or optimistic concurrency.

External net10.0 console; SDK10.0.403; Microsoft.EntityFrameworkCore.Sqlite 10.0.14; native SQLite relational provider. Portable practice is a separate .NET9 BCL-only program.

.NET10 / EF Core10 / real SQLite — Keys, entity states and change detection

Set up the SDK project

  1. Use an external .NET10 console environment. This downloadable project targets net10.0 and pins Microsoft.EntityFrameworkCore.Sqlite 10.0.14; it is not the SharpRune .NET9 browser practice.
  2. Restore this project with: dotnet restore EfLab.csproj --source https://api.nuget.org/v3/index.json. Use an installed compatible .NET10 SDK; validation used the isolated official SDK10.0.403.
  3. Run: dotnet run --project EfLab.csproj --no-restore. The source opens a real SQLite :memory: connection and keeps that connection alive across its sequential context scopes. No EF InMemory provider or fake ORM is used.
  4. Read the embedded Check assertions and the expected output below. EnsureCreated is used only for disposable classroom schema unless this is the dedicated migration lesson. A passing SQLite run is not evidence for another database provider.

Download complete SDK project ↗

Project files

EfLab.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable></PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.14" />
  </ItemGroup>
</Project>
Program.csDownload file ↗
using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<LabDb>().UseSqlite(connection).Options;
await using (var db = new LabDb(options))
{
    await db.Database.EnsureCreatedAsync(); var item = new Item { Name = "rune" }; db.Items.Add(item);
    Check(db.Entry(item).State == EntityState.Added, "added"); Console.WriteLine(db.Entry(item).State);
    await db.SaveChangesAsync(); Check(item.Id > 0 && db.Entry(item).State == EntityState.Unchanged, "saved identity"); Console.WriteLine(db.Entry(item).State);
    item.Name = "revised"; db.ChangeTracker.DetectChanges(); Check(db.Entry(item).State == EntityState.Modified, "detected edit"); Console.WriteLine(db.Entry(item).State);
    await db.SaveChangesAsync(); db.ChangeTracker.Clear(); Check(db.Entry(item).State == EntityState.Detached, "detached"); Console.WriteLine(db.Entry(item).State);
}
await using (var verify = new LabDb(options)) Check((await verify.Items.SingleAsync()).Name == "revised", "persisted rename");

static void Check(bool condition, string name) { if (!condition) throw new InvalidOperationException("CHECK: " + name); }
Model.csDownload file ↗
using Microsoft.EntityFrameworkCore;
public sealed class LabDb : DbContext
{
    public LabDb(DbContextOptions<LabDb> options) : base(options) { }
    public DbSet<Item> Items => Set<Item>();
}
public sealed class Item
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
}

Expected result

The external program must complete its Check assertions and print: Added Unchanged Modified Detached It uses actual SQLite materialization and saving; no external service or production database is contacted.

Use the listed SDK and dependencies for this project. Open C# practice runs the separate portable exercise.

C# companion practice

using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;

var edit = new Edit("rune", "rune"); Console.WriteLine(edit.Changed);
edit = edit with { Current = "revised" }; Console.WriteLine(edit.Changed);
record Edit(string Original, string Current) { public bool Changed => !StringComparer.Ordinal.Equals(Original, Current); }
Open C# practice

C# practice output

False
True
Try this
Call DetectChanges before deliberately observing the state of a property edit.

Further reading