Chapter 20 · SQLite Provider Deep Dive and Embedded-Database Constraints

SQLite Migrations and Table Rebuilds: DDL Limitations, Idempotency Boundaries, and Testing

Inspect how the EF Core SQLite provider rebuilds tables for unsupported ALTER operations, preserves modeled artifacts, limits idempotent scripting, and requires migration/data-integrity verification rather than server-database assumptions.

Advanced160–210 minutesrebuild migration + integrity labEF Core 10.0.11 · SQLite provider 10.0.11 · Microsoft.Data.Sqlite 10.0.11 · .NET 10.0.11 · SDK 10.0.400Free local SQLite file · native engine version captured at runtimeProvider deep-dive reviewed: August 2026

Learning outcomes

01

Explain why EF Core sometimes rebuilds a SQLite table instead of emitting one ALTER TABLE statement.

02

Inspect rebuild SQL as a data-copy operation with foreign keys, indexes, constraints, defaults, and failure/locking consequences.

03

Identify operations EF can rebuild only when every affected artifact is represented in the EF model.

04

Use bounded migration scripts or database update for SQLite instead of promising unsupported idempotent scripts.

05

Validate row preservation, foreign-key integrity, indexes, history state, and migration locks after schema evolution.

06

Treat backup/recovery and deployment ownership as part of a SQLite migration rather than relying on Down() as a safety net.

1. The problem: SQLite schema evolution is often copy-and-replace

Server databases expose broad ALTER TABLE capabilities. SQLite intentionally supports a smaller direct DDL surface. The EF Core SQLite provider compensates for many operations by rebuilding the table: create a replacement table, copy rows, drop/rename tables, and recreate modeled artifacts. That is a real data movement with lock, disk-space and recovery implications.

Migration operation SQLite provider behavior
AddColumn Directly supported
RenameColumn / RenameTable Directly supported
AlterColumn Provider rebuild
Add/Drop foreign key Provider rebuild
Add/Drop check or unique constraint Provider rebuild
DropColumn Provider rebuild
EnsureSchema / DropSchema No-op because SQLite has no schemas
A successful scaffold is not a migration review

The migration C# operation list, generated SQLite SQL, existing row distribution, indexes/triggers, lock behavior, and backup/reset path all need inspection before applying a rebuild to important data.

2. Use a realistic ServiceHub change that requires a rebuild

Suppose the business decides CustomerName must be non-empty and at most 160 characters at the database boundary. Adding a check constraint to an existing SQLite table requires a rebuild. The constraint makes the earlier Lesson 1 point concrete: a database invariant is different from a model facet.

csharp · model constraint that forces a SQLite rebuild
builder.ToTable("work_orders", table =>{    table.HasCheckConstraint(        "CK_work_orders_customer_name_length",        "length(customer_name) BETWEEN 1 AND 160");});
bash · scaffold and inspect before applying
dotnet ef migrations add SqliteCustomerNameConstraint   --project src/ServiceHub.EfLab   --startup-project src/ServiceHub.EfLabdotnet ef migrations script PreviousMigration SqliteCustomerNameConstraint   --project src/ServiceHub.EfLab   --startup-project src/ServiceHub.EfLab   --output artifacts/sqlite-customer-name-constraint.sql

3. Read the rebuild as a data-copy algorithm, not opaque provider noise

The exact temporary table names and statements are provider-version-specific, but the mechanism is stable. A representative rebuild looks like this:

sql · representative rebuild shape—inspect your generated SQL
CREATE TABLE "ef_temp_work_orders" (    "work_order_id" INTEGER NOT NULL CONSTRAINT "PK_work_orders" PRIMARY KEY AUTOINCREMENT,    "work_order_number" TEXT NOT NULL,    "customer_name" TEXT NOT NULL,    ...,    CONSTRAINT "CK_work_orders_customer_name_length"        CHECK (length(customer_name) BETWEEN 1 AND 160));INSERT INTO "ef_temp_work_orders" (...)SELECT ... FROM "work_orders";DROP TABLE "work_orders";ALTER TABLE "ef_temp_work_orders" RENAME TO "work_orders";CREATE UNIQUE INDEX ...;CREATE INDEX ...;

The copy step can fail if existing data violates the new constraint or cannot be converted. The rebuilt table must also restore modeled foreign keys, unique constraints and indexes. Therefore, validate data before applying the migration and compare schema metadata afterward.

4. Preflight the data before introducing a stricter constraint

A migration is the wrong moment to discover that thousands of rows violate a new invariant. Make the data profile observable first.

sql · preflight existing rows
SELECT work_order_id, customer_name, length(customer_name) AS lengthFROM work_ordersWHERE customer_name IS NULL   OR length(customer_name) NOT BETWEEN 1 AND 160;SELECT COUNT(*) AS work_orders_before FROM work_orders;PRAGMA foreign_key_check;PRAGMA integrity_check;

If invalid data exists, design an explicit data-cleanup/backfill migration or stop the deployment for human review. Do not silently truncate customer names merely to make DDL succeed.

5. Rebuilds can only safely recreate artifacts EF knows about

The SQLite provider documentation warns that rebuilds are possible only for database artifacts represented in the EF model. A trigger, custom index, virtual table, or other object created manually inside a migration may not be automatically reconstructed during a later provider rebuild.

sql · inventory non-table artifacts before a rebuild
SELECT type, name, tbl_name, sqlFROM sqlite_masterWHERE tbl_name = 'work_orders'  AND type IN ('index', 'trigger', 'view')ORDER BY type, name;
Manual database objects create migration ownership

If ServiceHub intentionally owns a trigger or special index outside EF model metadata, encode its lifecycle explicitly in reviewed migrations and add schema assertions. Otherwise a later rebuild can fail or alter the object unexpectedly.

6. Foreign keys and indexes must be verified after the rebuild

The model may say the right thing while the live database diverges after an interrupted/manual deployment. Verify both relationship constraints and indexes after the migration.

sql · post-migration SQLite schema checks
PRAGMA table_info('work_orders');PRAGMA foreign_key_list('work_orders');PRAGMA index_list('work_orders');PRAGMA foreign_key_check;PRAGMA integrity_check;SELECT COUNT(*) AS work_orders_after FROM work_orders;

Compare deterministic row counts and representative values from before/after the rebuild. integrity_check is useful engine evidence, but it does not prove business data transformations were semantically correct.

7. SQLite idempotent migration scripts remain unsupported

SQLite does not have the procedural if/then machinery EF uses to conditionally apply each missing migration in an idempotent script. Therefore dotnet ef migrations script --idempotent is not the SQLite deployment path. If the current migration is known, generate a bounded script from that migration to the target. Otherwise use a coordinated EF migration application path such as dotnet ef database update against the chosen file.

bash · bounded script from a known state
dotnet ef migrations script 202608270900_PreviousMigration 202608271000_SqliteCustomerNameConstraint   --output artifacts/sqlite-bounded.sql# Or, for a coordinated local deployment:dotnet ef database update   --connection "Data Source=servicehub-sqlite-deepdive.db;Foreign Keys=True"
Database.Migrate at every app startup is not a universal production strategy

Even with migration locking, application instances may lack DDL permissions, upgrades may require review/backup/maintenance windows, and long rebuilds can block work. Assign one migration owner in the deployment process.

8. Concurrent migration protection uses __EFMigrationsLock

EF Core 9 introduced provider migration locking. The SQLite provider implements this with an __EFMigrationsLock table. A crash can leave that table behind and block later EF-driven migrations. Manual cleanup is appropriate only after proving that no migration process is active and after understanding the deployment state.

sql · inspect migration/history/lock state
SELECT name, sqlFROM sqlite_masterWHERE name IN ('__EFMigrationsHistory', '__EFMigrationsLock');SELECT * FROM __EFMigrationsHistory ORDER BY MigrationId;

Externally executing a generated SQL script does not inherit EF’s migration lock. The deployment pipeline itself must serialize such script execution.

9. Failure case: a rebuild collides with an unmanaged trigger

An earlier migration created a trigger with raw SQL but no later migration test inventories it. A new AlterColumn causes a rebuild. Depending on the operation, EF may throw NotSupportedException because it cannot safely rebuild the unmanaged artifact, or the hand-maintained lifecycle can drift from the model.

Repair: decide ownership. Either represent the required behavior using EF-supported model constructs, or own the trigger explicitly with create/drop/recreate migration SQL plus schema verification. Never respond by deleting production triggers until the migration happens to run.

10. Mandatory lab: prove row and schema preservation through a rebuild

  1. Copy servicehub-sqlite-deepdive.db to a backup filename while the lab application is stopped.
  2. Seed deterministic work orders and record row count, selected values, foreign-key check, integrity check, indexes and a file hash.
  3. Add the customer-name check constraint and scaffold SqliteCustomerNameConstraint.
  4. Inspect the generated migration operation and bounded SQL script; identify the replacement table, copy, drop/rename and index recreation.
  5. Apply the migration once through a single coordinated owner.
  6. Verify row counts/values, PRAGMA foreign_key_check, PRAGMA integrity_check, PRAGMA index_list, and __EFMigrationsHistory.
  7. Attempt an invalid insert and confirm the database constraint—not only C# validation—rejects it.
  8. Restore the backup or delete the disposable database after recording evidence.

11. Production judgment and bridge

SQLite migration safety is strongly tied to data size, file locking, disk space and artifact ownership because many schema changes are rebuilds. Review generated SQL, preflight data, back up correctly, serialize migration ownership and verify postconditions. Do not use unsupported idempotent scripts or EnsureCreated as substitutes for a migrations lifecycle. Lesson 3 now moves from schema-time locking to runtime concurrency, where SQLite’s single-writer design must be separated from EF optimistic conflicts.

Check your understanding

  1. Why does EF Core rebuild some SQLite tables?
  2. Why can a rebuild fail even when the migration C# looks reasonable?
  3. Can EF Core generate an idempotent migration script for SQLite?
  4. What does __EFMigrationsLock protect?
  5. Is Down() a backup?
  6. What evidence should close a rebuild migration?
Review the answers

1. SQLite cannot directly express many ALTER operations, so the provider creates a replacement table, copies data, swaps it in, and recreates modeled artifacts.

2. Existing data may violate the new schema, or the database may contain unmanaged artifacts that EF cannot safely reconstruct.

3. No. Use a bounded script from a known migration state or a coordinated EF database-update path.

4. Concurrent EF-driven migration execution for SQLite; it does not serialize externally executed SQL scripts.

5. No. It is reverse migration code and may itself be destructive; operational backups/recovery are separate responsibilities.

6. Data checks, row counts, PRAGMA foreign_key_check/integrity_check, index/schema metadata, migration history, and business-specific verification.

Authoritative references

SQLite migrations mix EF model operations with engine DDL constraints; inspect both provider and SQLite documentation.

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.

\n