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.
Choose creation or versioned evolution before initializing storage
EnsureCreated and migrations serve different schema strategies. The former initializes disposable storage from the current model when appropriate; it does not create a migration history that later tools can seamlessly adopt. A migrations workflow instead records source changes, a model snapshot and applied history. This project intentionally chooses migrations and never calls EnsureCreated. The in-memory fixture still makes repeated test runs disposable, but it exercises actual migration application rather than claiming a version-label list is a schema tool. Do not transplant this fixture’s initialization into a production startup path without a reviewed deployment policy.
Apply the included genuine migration and inspect history
The downloadable source includes Initial and its generated metadata plus LabDbModelSnapshot, scaffolded by the matching EF10 tool. Program opens a real SQLite connection, calls MigrateAsync and verifies that one applied migration ends in Initial. Calling MigrateAsync again leaves one history entry. It then inserts migrated and materializes that row from the migration-created table. These assertions establish this initial migration’s application and repeatability on SQLite. The design-time factory is a separate tooling entry point with its own named local connection, while ordinary execution supplies the held-open in-memory connection explicitly.
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.MigrateAsync(); var applied = (await db.Database.GetAppliedMigrationsAsync()).ToArray();
Check(applied.Length == 1 && applied[0].EndsWith("_Initial", StringComparison.Ordinal), "real initial migration applied");
await db.Database.MigrateAsync(); Check((await db.Database.GetAppliedMigrationsAsync()).Count() == 1, "migration history idempotence");
db.Items.Add(new Item { Name = "migrated" }); await db.SaveChangesAsync(); db.ChangeTracker.Clear(); Check((await db.Items.SingleAsync()).Name == "migrated", "migration-created schema materializes");
Console.WriteLine("history:1"); Console.WriteLine("rows:1");
static void Check(bool condition, string name) { if (!condition) throw new InvalidOperationException("CHECK: " + name); }
A version-label difference is a plan, not a migration engine
The .NET9 practice receives an applied-label set containing Initial and a planned sequence containing Initial followed by AddSku. Its output AddSku identifies the supplied label not yet in that set. It does not compare EF models, generate operations, produce SQL or update a history table. That narrow planning result can still be useful when learning why applied history and desired changes are separate inputs. Keep the real tool-generated source and actual provider execution as independent evidence; inventing a custom class that moves labels between lists would not establish schema migration correctness.
Review generated operations and provider limitations
A scaffolded migration describes a proposed change, not an automatically safe production rollout. Column removals, default values, data conversions and table rebuilds need review against existing data. SQLite supports a different set of schema operations from SQL Server or PostgreSQL, and some changes require provider-specific rebuild behavior. This lesson applies only a generated initial table to a fresh fixture. It does not test a live upgrade, rollback recovery, backups or a multi-instance deployment. The snapshot is also part of the migration source contract; dropping it from the download can change what later tooling thinks the previous model was.
Practice: scaffold one additional reviewed change locally
In an external working copy, install the pinned dotnet-ef into a local tool directory rather than globally. Add a permitted property, scaffold a new migration and inspect both its operations and snapshot change before running anything against data. Generate a SQL script for review, then apply the migration only to an owned disposable database and verify preserved rows. Keep this extension separate from the initial fixture assertion and do not claim that a successful scaffold already deployed a schema. A real production release needs its own access, backup, sequencing and compatibility decisions.
- Hint: keep migration source and its snapshot together in the project.
- Hint: do not call EnsureCreated on a schema that this workflow intends to manage with migrations.
Practice: detect inconsistent planning input without inventing SQL
Extend the supplied labels with an unknown applied version, a duplicate planned label and an out-of-order plan. Decide which conditions are invalid under the application’s stated planning convention and return named diagnostics. Do not assume a set difference alone can establish whether EF would safely apply those changes. That exercise remains ordinary data validation and should not introduce a fake migration executor. Use the actual EF tooling for model comparison, then validate the resulting migration on the intended provider with realistic prior data when the feature becomes a genuine schema evolution task.
- Hint: a label’s presence is not proof that its operations match the current model.
- Hint: distinguish planning validation, scaffolding, SQL review and database application.
Keep the verified migration scope precise
The external check proves one genuine Initial migration and its history behavior on a fresh SQLite in-memory database, followed by a real insert and query. It does not verify SQL Server, PostgreSQL or a production deployment. The portable practice proves only its supplied pending label. A useful receipt names the SDK, EF/provider version, tool version, included migration files, initialization strategy and executed database checks. Preserve those facts instead of describing EnsureCreated as a convenient first migration or treating a successful tool command as a complete data-preserving release plan.
.NET10 / EF Core10 / real SQLite — Migrations and EnsureCreated are different paths
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.
- The included migration files are part of the project. To author a later change in an external working copy, install matching tools locally, not globally: dotnet tool install dotnet-ef --tool-path .tools --version 10.0.14 --add-source https://api.nuget.org/v3/index.json; then .tools/dotnet-ef migrations add YourChange --project EfLab.csproj. Review generated source and SQL before any database update.
- The design-time factory names a separate local design database for tooling. The ordinary program instead supplies its explicitly owned in-memory connection. Generating a migration does not prove a production schema deployment.
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" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.14" PrivateAssets="all" />
</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.MigrateAsync(); var applied = (await db.Database.GetAppliedMigrationsAsync()).ToArray();
Check(applied.Length == 1 && applied[0].EndsWith("_Initial", StringComparison.Ordinal), "real initial migration applied");
await db.Database.MigrateAsync(); Check((await db.Database.GetAppliedMigrationsAsync()).Count() == 1, "migration history idempotence");
db.Items.Add(new Item { Name = "migrated" }); await db.SaveChangesAsync(); db.ChangeTracker.Clear(); Check((await db.Items.SingleAsync()).Name == "migrated", "migration-created schema materializes");
Console.WriteLine("history:1"); Console.WriteLine("rows:1");
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; } = "";
}
LabDbFactory.cs
Download file ↗using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
public sealed class LabDbFactory : IDesignTimeDbContextFactory<LabDb>
{
public LabDb CreateDbContext(string[] args) => new LabDb(new DbContextOptionsBuilder<LabDb>().UseSqlite("Data Source=ef-lab-design.db").Options);
}
Migrations/20261106121449_Initial.Designer.cs
Download file ↗// <auto-generated />
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace EfLab.Migrations
{
[DbContext(typeof(LabDb))]
[Migration("20261106121449_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.14");
modelBuilder.Entity("Item", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Items");
});
#pragma warning restore 612, 618
}
}
}
Migrations/20261106121449_Initial.cs
Download file ↗using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EfLab.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Items",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Items", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Items");
}
}
}
Migrations/LabDbModelSnapshot.cs
Download file ↗// <auto-generated />
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace EfLab.Migrations
{
[DbContext(typeof(LabDb))]
partial class LabDbModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.14");
modelBuilder.Entity("Item", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Items");
});
#pragma warning restore 612, 618
}
}
}
Expected result
The external program must complete its Check assertions and print: history:1 rows:1 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 applied = new HashSet<string>(StringComparer.Ordinal) { "Initial" };
var planned = new[] { "Initial", "AddSku" };
Console.WriteLine(string.Join(",", planned.Where(label => !applied.Contains(label))));
C# practice output
AddSku
Use migrations from the start when schema evolution is required; do not initialize that schema with EnsureCreated.