Safe migration workflow (.NET)
Migrations are where a working codebase quietly acquires risk: a column drop that loses data, a framework bump that breaks at runtime not compile time, a transitive package that shifts behavior under you. The defense is the same four rules in every flow below.
- Preview before you apply. Read the generated SQL, the breaking-change list, the changelog - never run a step blind.
- Carry a rollback. Know the exact command or commit that undoes the step before you take it.
- Re-verify after every step. Build and run the tests; a green pre-flight that you never re-check proves nothing.
- One logical change per step. A migration, an upgrade, a bump - keep them atomic so a break bisects cleanly.
Assess blast radius with serena (find_symbol, find_referencing_symbols) or the LSP. Do not Read whole files hunting for who touches a type - that is exactly the work the symbol tools do faster.
Flow A - EF Core schema migration
- See where you are.
dotnet ef migrations list shows what is applied versus pending. Use serena to find the entities you are about to change and everything that references them.
- Generate one named migration.
dotnet ef migrations add <Name> - name it Verb-then-subject so the history reads as a log: AddOrderShippedAt, MakeEmailUnique, DropLegacyStatus. The one-change-per-migration discipline itself is database-conventions's to state; this step is just the EF naming and generation mechanics.
- Preview the SQL.
dotnet ef migrations script --idempotent (or --idempotent <from> <to> for a range). Read it for the dangerous shapes: dropped or renamed columns, a non-nullable add with no default, a type narrowing that truncates, an index or constraint added to a large table under a lock. The --idempotent flag guards each step with an __EFMigrationsHistory check so the script is safe to run against a database at any applied state, and re-running it is a no-op (provider-dependent - the SQLite provider has no idempotent scripts, so preview a plain script <from> <to> there). EF cannot see your data - you have to.
- Stage destructive change in two deploys. Anything that can lose data or that the old code still depends on is expand-then-contract: first add the new column and backfill (the old code keeps working), ship, then in a later migration drop the old column once nothing reads it. Never collapse both halves into one migration against a live database. A wide backfill is a data migration, not a schema one - run it in batches outside the
ALTER, never as a single UPDATE under a table lock.
- Apply and verify. In dev,
dotnet ef database update, then build and run the tests. For anything past a local box, do not apply from a developer machine or call Database.Migrate() on app start under load (it serializes startup and needs schema-owner rights at runtime). Build a migrations bundle once - dotnet ef migrations bundle -o efbundle (add --self-contained -r <rid> to fold the runtime in too) - and run that single-file executable as a gated deploy step. Like dotnet ef database update it checks __EFMigrationsHistory and applies only the migrations still missing, so it needs neither the SDK, the EF tool, nor the project source on the target and is the zero-downtime artifact.
- Know the undo. Roll the database back with
dotnet ef database update <PreviousMigration>, then delete the migration files with dotnet ef migrations remove (which un-snapshots cleanly - never delete the files by hand). Never hand-edit an already-applied migration - add a new one. Once a migration has shipped to any shared environment its rollback is a new forward migration, not a remove. Query, tracking, and configuration mechanics live in dotnet-data-access.
Flow B - target framework / SDK upgrade
- Start clean. Green tests and zero pending migrations before you touch a version - you want any new red to be unambiguously the upgrade's fault.
- Move the SDK first. Bump
global.json if it pins one, then <TargetFramework> and <LangVersion> in each project. Sweep the whole solution for stragglers on the old TFM - a mixed-framework solution is its own class of bug.
- Match the packages to the framework. Update Microsoft and third-party packages to the line that targets the new framework, build, and work through the breaks. The official breaking-changes list for the release is the map; read it rather than guessing at each error. For a large or legacy solution an automated sweep drives the mechanical TFM and package bumps and surfaces the analyzers - the .NET Upgrade Assistant (
dotnet tool install -g upgrade-assistant) still ships but is now deprecated in favor of the GitHub Copilot app modernization agent; whichever you use, still read the breaking-changes list for the behavioral breaks it cannot catch.
- Adopt new features on purpose. A release's additions (
TimeProvider for testable time, HybridCache, keyed services, primary constructors) are opt-in - take them where they pay, in their own follow-up commits, not bundled into the bump.
- Verify the full set. Build, test, then run the repo's formatter (
dotnet-code-quality owns the pick - CSharpier or dotnet format, never both) so the diff is upgrade-only and not noise. The floor is .NET 8 / C# 12; how far you go above it is the target you chose.
A .NET Framework 4.8 -> modern .NET move is more than a TFM bump - different programming models and hard blockers (WebForms, server-side WCF, .NET Remoting, AppDomains). Its stance and upgrade-vs-replace blocker map are in references/net-framework-48.md.
Flow C - NuGet package updates
- Audit first.
dotnet list package --outdated for what is behind, dotnet list package --vulnerable (add --include-transitive) for what is unsafe - the same audit trio the .NET solution-setup skill's central-package-management reference owns for routine dependency hygiene, when your skill list has one. Security fixes jump the queue.
- Sort by semver risk. Patch and minor are usually safe; a major is a contract change - read its release notes before you commit to it. Group the work so the riskiest bumps are isolated.
- One package at a time. Update a package, build, test, then the next. A single-package step is the only step you can bisect; a mass bump turns one regression into a hunt across a dozen libraries.
- Centralize versions. With central package management the version lives once in
Directory.Packages.props, not scattered across .csproj files - that mechanism is dotnet-project-setup.
- Undo cleanly. Back out a bad update with
git revert of that step's commit. Never silently downgrade an unrelated dependency to paper over the break.
1---2name: dotnet-migrate3description: safe-migration playbook for .NET - the disciplined way to evolve a schema with EF Core, lift a solution onto a newer target framework, and refresh NuGet dependencies without breaking production. A process skill, version-neutral with a .NET 8 floor. Load when you run a database migration, raise the target framework or SDK, or bump packages - trigger words migrate, upgrade, update packages. Companions: `dotnet-data-access` for query and migration mechanics, `dotnet-project-setup` for central package management, `dotnet-web-backend` for the surrounding service. Skip it for ordinary feature work that touches no schema, no version, and no package.4---56# Safe migration workflow (.NET)78Migrations are where a working codebase quietly acquires risk: a column drop that loses data, a framework bump that breaks at runtime not compile time, a transitive package that shifts behavior under you. The defense is the same four rules in every flow below.910- **Preview before you apply.** Read the generated SQL, the breaking-change list, the changelog - never run a step blind.11- **Carry a rollback.** Know the exact command or commit that undoes the step *before* you take it.12- **Re-verify after every step.** Build and run the tests; a green pre-flight that you never re-check proves nothing.13- **One logical change per step.** A migration, an upgrade, a bump - keep them atomic so a break bisects cleanly.1415Assess blast radius with serena (`find_symbol`, `find_referencing_symbols`) or the LSP. Do not `Read` whole files hunting for who touches a type - that is exactly the work the symbol tools do faster.1617## Flow A - EF Core schema migration18191. **See where you are.** `dotnet ef migrations list` shows what is applied versus pending. Use serena to find the entities you are about to change and everything that references them.202. **Generate one named migration.** `dotnet ef migrations add <Name>` - name it Verb-then-subject so the history reads as a log: `AddOrderShippedAt`, `MakeEmailUnique`, `DropLegacyStatus`. The one-change-per-migration discipline itself is `database-conventions`'s to state; this step is just the EF naming and generation mechanics.213. **Preview the SQL.** `dotnet ef migrations script --idempotent` (or `--idempotent <from> <to>` for a range). Read it for the dangerous shapes: dropped or renamed columns, a non-nullable add with no default, a type narrowing that truncates, an index or constraint added to a large table under a lock. The `--idempotent` flag guards each step with an `__EFMigrationsHistory` check so the script is safe to run against a database at any applied state, and re-running it is a no-op (provider-dependent - the SQLite provider has no idempotent scripts, so preview a plain `script <from> <to>` there). EF cannot see your data - you have to.224. **Stage destructive change in two deploys.** Anything that can lose data or that the old code still depends on is expand-then-contract: first add the new column and backfill (the old code keeps working), ship, then in a later migration drop the old column once nothing reads it. Never collapse both halves into one migration against a live database. A wide backfill is a data migration, not a schema one - run it in batches outside the `ALTER`, never as a single `UPDATE` under a table lock.235. **Apply and verify.** In dev, `dotnet ef database update`, then build and run the tests. For anything past a local box, do not apply from a developer machine or call `Database.Migrate()` on app start under load (it serializes startup and needs schema-owner rights at runtime). Build a migrations bundle once - `dotnet ef migrations bundle -o efbundle` (add `--self-contained -r <rid>` to fold the runtime in too) - and run that single-file executable as a gated deploy step. Like `dotnet ef database update` it checks `__EFMigrationsHistory` and applies only the migrations still missing, so it needs neither the SDK, the EF tool, nor the project source on the target and is the zero-downtime artifact.246. **Know the undo.** Roll the database back with `dotnet ef database update <PreviousMigration>`, then delete the migration files with `dotnet ef migrations remove` (which un-snapshots cleanly - never delete the files by hand). Never hand-edit an already-applied migration - add a new one. Once a migration has shipped to any shared environment its rollback is a *new* forward migration, not a `remove`. Query, tracking, and configuration mechanics live in `dotnet-data-access`.2526## Flow B - target framework / SDK upgrade27281. **Start clean.** Green tests and zero pending migrations before you touch a version - you want any new red to be unambiguously the upgrade's fault.292. **Move the SDK first.** Bump `global.json` if it pins one, then `<TargetFramework>` and `<LangVersion>` in each project. Sweep the whole solution for stragglers on the old TFM - a mixed-framework solution is its own class of bug.303. **Match the packages to the framework.** Update Microsoft and third-party packages to the line that targets the new framework, build, and work through the breaks. The official breaking-changes list for the release is the map; read it rather than guessing at each error. For a large or legacy solution an automated sweep drives the mechanical TFM and package bumps and surfaces the analyzers - the .NET Upgrade Assistant (`dotnet tool install -g upgrade-assistant`) still ships but is now deprecated in favor of the GitHub Copilot app modernization agent; whichever you use, still read the breaking-changes list for the behavioral breaks it cannot catch.314. **Adopt new features on purpose.** A release's additions (`TimeProvider` for testable time, `HybridCache`, keyed services, primary constructors) are opt-in - take them where they pay, in their own follow-up commits, not bundled into the bump.325. **Verify the full set.** Build, test, then run the repo's formatter (`dotnet-code-quality` owns the pick - CSharpier or `dotnet format`, never both) so the diff is upgrade-only and not noise. The floor is .NET 8 / C# 12; how far you go above it is the target you chose.3334A .NET Framework 4.8 -> modern .NET move is more than a TFM bump - different programming models and hard blockers (WebForms, server-side WCF, .NET Remoting, AppDomains). Its stance and upgrade-vs-replace blocker map are in `references/net-framework-48.md`.3536## Flow C - NuGet package updates37381. **Audit first.** `dotnet list package --outdated` for what is behind, `dotnet list package --vulnerable` (add `--include-transitive`) for what is unsafe - the same audit trio the .NET solution-setup skill's central-package-management reference owns for routine dependency hygiene, when your skill list has one. Security fixes jump the queue.392. **Sort by semver risk.** Patch and minor are usually safe; a major is a contract change - read its release notes before you commit to it. Group the work so the riskiest bumps are isolated.403. **One package at a time.** Update a package, build, test, then the next. A single-package step is the only step you can bisect; a mass bump turns one regression into a hunt across a dozen libraries.414. **Centralize versions.** With central package management the version lives once in `Directory.Packages.props`, not scattered across `.csproj` files - that mechanism is `dotnet-project-setup`.425. **Undo cleanly.** Back out a bad update with `git revert` of that step's commit. Never silently downgrade an unrelated dependency to paper over the break.