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.
A read-only intention should stay distinct from an update intention
A query can materialize a real entity without registering it in the context’s normal tracker. AsNoTracking expresses that choice for a read workflow, often making the ownership of the returned data easier to understand. It is not a security feature that makes the object immutable: a caller can still assign its C# properties. The important question is whether this context will treat those assignments as pending database work. This lesson observes that boundary directly instead of making a performance claim from one tiny query or treating the phrase read-only as a guarantee enforced on every property.
Verify both the untracked edit and a deliberate tracked update
The external project creates the row, clears seeding state and queries it with AsNoTracking. It checks that ChangeTracker contains no entities, changes the detached name and verifies that SaveChangesAsync reports no pending changes. A new context still reads rune from SQLite. That context then performs a separate ordinary tracked load, assigns saved and persists it. A final fresh read verifies saved. These steps avoid a misleading test that prints only the detached object’s changed value. The genuine SQLite observations establish which update actually reached storage, while each context remains short-lived and sequentially awaited.
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(); db.Items.Add(new Item { Name = "rune" }); await db.SaveChangesAsync(); db.ChangeTracker.Clear();
var detached = await db.Items.AsNoTracking().SingleAsync(); Check(!db.ChangeTracker.Entries().Any(), "untracked result"); detached.Name = "draft";
Check(await db.SaveChangesAsync() == 0, "no pending tracked update");
}
await using (var verify = new LabDb(options))
{
Check((await verify.Items.AsNoTracking().SingleAsync()).Name == "rune", "untracked edit not saved"); Console.WriteLine("stored:rune");
var tracked = await verify.Items.SingleAsync(); tracked.Name = "saved"; await verify.SaveChangesAsync();
}
await using (var verify = new LabDb(options)) { Check((await verify.Items.SingleAsync()).Name == "saved", "tracked edit saved"); Console.WriteLine("stored:saved"); }
static void Check(bool condition, string name) { if (!condition) throw new InvalidOperationException("CHECK: " + name); }
Trace an independent view copy
The BCL companion creates an immutable View record named rune and then another record named draft using with. Printing the first still gives rune and printing the second gives draft. This demonstrates an independent value-oriented view representation, not AsNoTracking itself. A record with mutable nested references would require additional ownership decisions, and a class returned by EF does not automatically become immutable because the query is untracked. Keep the model’s purpose precise: it supplies a convenient way for a presentation layer to work with a copy without pretending that a database tracker participated.
No-tracking, identity resolution and projection are related choices
No-tracking queries do not perform the normal context identity resolution that a tracking query provides. A dedicated identity-resolution variant can deduplicate instances during one result enumeration without adopting ordinary tracking lifetime. Also inspect projections: returning an entity inside an anonymous wrapper can still cause entity tracking, while projecting only scalars does not create that tracked entity. Avoid treating every Select as an automatic no-tracking operation. The correct shape depends on whether the caller needs a presentation model, a consistent object graph or an updateable tracked entity, and its costs should be measured on representative results.
Practice: verify a no-tracking result after mutation
Add a second scalar field, materialize it without tracking, and alter both properties. Save and query from another context to verify that neither assignment silently became an update. Then design a separate explicit update method accepting only the permitted fields and loading the target row into a new tracked context. Compare the two responsibilities rather than attaching the presentation object indiscriminately. Include a missing-row result and a concurrency policy when that update method moves beyond the classroom. A no-tracking read is not proof that the later write can safely ignore other users’ changes.
- Hint: verify stored values with a fresh query, not by printing the mutated object.
- Hint: an update boundary should define allowed fields and missing-row behavior.
Practice: define whether a view owns nested values
Extend the portable view with a collection of labels and choose whether callers receive an immutable collection, a copied array or a borrowed mutable list. Demonstrate the resulting behavior when one caller changes its view. This is a separate object-ownership question from query tracking. Write expectations for a copied outer record with a shared inner list before assuming with performs a deep clone. The exercise should leave database APIs absent so its deterministic result is still meaningful in SharpRune’s .NET9 runtime. Only the real external project can verify how an EF query materializes and tracks its chosen shape.
- Hint: a new outer record can still share a mutable nested object.
- Hint: state the view’s mutation contract independently of EF.
Choose read shapes for a concrete caller
The successful provider run proves one untracked mutation was not saved and one separately tracked mutation was saved. It does not establish a universal performance ratio, eliminate database work or provide an authorization boundary. A useful review names the query’s result shape, tracking mode, result lifetime and any later update operation. Keep the .NET10/EF10 provider test separate from the .NET9 record-copy practice, and test another production provider where translation or identity behavior matters. That precision makes read-only screens, exports and background processing easier to reason about without accidentally carrying a long-lived context into the presentation layer.
.NET10 / EF Core10 / real SQLite — Read-only queries and AsNoTracking
Set up the SDK project
- 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.
- 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.
- 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.
- 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.csproj
Download 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.cs
Download 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(); db.Items.Add(new Item { Name = "rune" }); await db.SaveChangesAsync(); db.ChangeTracker.Clear();
var detached = await db.Items.AsNoTracking().SingleAsync(); Check(!db.ChangeTracker.Entries().Any(), "untracked result"); detached.Name = "draft";
Check(await db.SaveChangesAsync() == 0, "no pending tracked update");
}
await using (var verify = new LabDb(options))
{
Check((await verify.Items.AsNoTracking().SingleAsync()).Name == "rune", "untracked edit not saved"); Console.WriteLine("stored:rune");
var tracked = await verify.Items.SingleAsync(); tracked.Name = "saved"; await verify.SaveChangesAsync();
}
await using (var verify = new LabDb(options)) { Check((await verify.Items.SingleAsync()).Name == "saved", "tracked edit saved"); Console.WriteLine("stored:saved"); }
static void Check(bool condition, string name) { if (!condition) throw new InvalidOperationException("CHECK: " + name); }
Model.cs
Download 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: stored:rune stored:saved 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 saved = new View("rune"); var edited = saved with { Name = "draft" };
Console.WriteLine(saved.Name); Console.WriteLine(edited.Name);
record View(string Name);
C# practice output
rune draft
No-tracking changes the context’s tracking behavior, not the fact that a SQL query runs.