Skip to content

Repository files navigation

EntityFrameworkCore.Sqlite.Concurrency

NuGet VersionDownloadsCILicense: MIT.NET 10

Getting this error?

Microsoft.Data.Sqlite.SqliteException: SQLite Error 5: 'database is locked'

This is the fix. One line change, no other modifications to your code:

// Before:options.UseSqlite("Data Source=app.db");// After — eliminates SQLITE_BUSY, serializes writes, enables WAL-mode parallel reads:options.UseSqliteWithConcurrency("Data Source=app.db");

What Problems This Solves

Error / ExceptionRoot CauseFix
SQLite Error 5: 'database is locked' (SQLITE_BUSY)Multiple writers contending simultaneouslyChannel-based write queue — one writer at a time, all others park efficiently in FIFO order
SQLite Error 5: 'database is locked' (extended code 517, SQLITE_BUSY_SNAPSHOT)WAL read snapshot became stale mid-transactionBEGIN IMMEDIATE upgrade + full operation restart on snapshot staleness
InvalidOperationException: A second operation was started on this context instanceDbContext shared across concurrent tasksIDbContextFactory<T> registration — one independent context per concurrent flow
Bulk inserts slow or running out of memory on large datasetsLinear SaveChanges(), unbounded ChangeTracker growth per batchBulkInsertOptimizedAsync — batched writes, ChangeTracker.Clear() per batch, WAL-optimized transactions

See Troubleshooting SQLITE_BUSY errors for a deep-dive on each error code.


Why Choose This Package?

Problem with Standard EF Core + SQLiteSolution
SQLITE_BUSY / database is locked under concurrent writesChannel-based write queue serializes all writes — zero contention errors
SQLITE_BUSY_SNAPSHOT mid-transactionAutomatic BEGIN IMMEDIATE upgrade prevents stale read snapshots
Slow bulk insertsBulkInsertOptimizedAsync — typically 5–10x faster via batching and PRAGMA tuning
Read contention during writesWAL mode enabled automatically — reads never block behind writes
Manual retry logicBuilt-in exponential backoff with full jitter for all SQLITE_BUSY* variants
DbContext not thread-safeIDbContextFactory<T> wiring and ThreadSafeSqliteContext<T> base class

Installation

# .NET CLI
dotnet add package EntityFrameworkCore.Sqlite.Concurrency
# Package Manager Console
Install-Package EntityFrameworkCore.Sqlite.Concurrency

Usage

Request-Scoped Apps (ASP.NET Core, Razor Pages, Blazor Server)

One DbContext per HTTP request. ASP.NET Core scopes handle thread safety at the request level.

// Program.csbuilder.Services.AddConcurrentSqliteDbContext<AppDbContext>("Data Source=app.db");// Inject normally — concurrent writes across requests are serialized automaticallypublicclassPostsController:ControllerBase{privatereadonlyAppDbContext_db;publicPostsController(AppDbContextdb)=>_db=db;[HttpPost]publicasyncTask<IActionResult>Create(Postpost){_db.Posts.Add(post);await_db.SaveChangesAsync();// thread-safe across concurrent requestsreturnOk();}}

Concurrent Workloads (Background Services, Task.WhenAll, Channel Consumers)

DbContext is not thread-safe — never share one instance across concurrent tasks. Use IDbContextFactory<T> to give each concurrent flow its own independent context. Writes are still serialized at the SQLite level automatically.

// Program.csbuilder.Services.AddConcurrentSqliteDbContextFactory<AppDbContext>("Data Source=app.db");// Background service — each task gets its own contextpublicclassReportService{privatereadonlyIDbContextFactory<AppDbContext>_factory;publicReportService(IDbContextFactory<AppDbContext>factory)=>_factory=factory;publicasyncTaskProcessAllAsync(IEnumerable<int>ids,CancellationTokenct){vartasks=ids.Select(async id =>{awaitusingvardb=_factory.CreateDbContext();// one context per taskvaritem=awaitdb.Items.FindAsync(id,ct);item.ProcessedAt=DateTime.UtcNow;awaitdb.SaveChangesAsync(ct);// writes serialized — no "database is locked"});awaitTask.WhenAll(tasks);// all complete without SQLITE_BUSY}}

See Concurrent EF Core patterns for the full pattern guide.

High-Volume Batch Jobs

// 10,000 records — batched in chunks of 1,000, ChangeTracker cleared between batchesawaitcontext.BulkInsertOptimizedAsync(records);// Or with explicit retry controlawaitcontext.ExecuteWithRetryAsync(async ctx =>{foreach(varrecordinrecords)ctx.Records.Add(record);},maxRetries:5);

Configuration

builder.Services.AddConcurrentSqliteDbContext<AppDbContext>("Data Source=app.db",
options =>{options.BusyTimeout=TimeSpan.FromSeconds(30);options.MaxRetryAttempts=3;options.CommandTimeout=300;options.WalAutoCheckpoint=1000;options.SynchronousMode=SqliteSynchronousMode.Normal;options.UpgradeTransactionsToImmediate=true;options.WriteQueueCapacity=null;// null = unbounded});
OptionDefaultDescription
BusyTimeout30 sPRAGMA busy_timeout — SQLite retries lock acquisition internally before surfacing SQLITE_BUSY.
MaxRetryAttempts3Application-level retries after SQLITE_BUSY*, with exponential backoff and full jitter.
CommandTimeout300 sEF Core SQL command timeout.
WalAutoCheckpoint1000 pagesPRAGMA wal_autocheckpoint — triggers passive checkpoint after this many WAL frames (~4 MB). Set to 0 to disable.
SynchronousModeNormalPRAGMA synchronous — durability vs. write-speed. Normal is recommended for WAL mode.
UpgradeTransactionsToImmediatetrueRewrites BEGIN to BEGIN IMMEDIATE to prevent SQLITE_BUSY_SNAPSHOT mid-transaction.
WriteQueueCapacitynullMaximum pending writes before callers block. null = accept without limit. Set a value for back-pressure on high-volume producers.

Note:Cache=Shared in the connection string is incompatible with WAL mode and will throw ArgumentException at startup. Connection pooling (Pooling=true) is enabled automatically and is the correct alternative.


FAQ

Q: What is the Channel-based write queue? A: Since v10.1.0, write serialization uses Channel<IWriteRequest> with a single background writer instead of SemaphoreSlim. Under high concurrency, SemaphoreSlim wakes all N waiters simultaneously when the lock releases — N-1 immediately re-sleep (CPU churn, unfair scheduling, thundering herd). A Channel<T> parks callers in FIFO order and drains them one at a time. No API change is needed — existing code gets the benefit automatically.

Q: What does SQLITE_BUSY_SNAPSHOT (error code 517) mean? A: This is a specific variant of SQLITE_BUSY (extended code 5 | (2 << 8) = 517) that fires when a connection's WAL read snapshot became stale after another writer committed mid-transaction. Retrying the same statement produces the same error — the only correct fix is to roll back and restart the entire operation so all reads are re-queried against the current snapshot. This package implements that correctly.

Q: Does this work with netstandard 2.0? A: The connection layer (SqliteConnectionEnhancer, SqliteWriteQueue, SqliteConcurrencyOptions) targets netstandard2.0. EF Core-dependent APIs (UseSqliteWithConcurrency, ThreadSafeSqliteContext<T>, AddConcurrentSqliteDbContext<T>) require net10.0 because EF Core 10 requires it.

Q: How much overhead does write serialization add? A: Sub-millisecond. Enqueueing to a Channel and awaiting a TaskCompletionSource adds microseconds per write — negligible compared to actual SQLite I/O. WAL-mode PRAGMA tuning typically more than compensates on read-heavy workloads.

Q: Does this work with my existing DbContext, models, and LINQ queries? A: Yes. Change UseSqlite to UseSqliteWithConcurrency. Everything else compiles and runs unchanged.

Q: Does this work on network filesystems? A: No. SQLite WAL mode requires all connections to be on the same physical host. Do not point the database at an NFS, SMB, or other network-mounted path. Use a client/server database (PostgreSQL, SQL Server) for multi-host deployments.

Q: Why do I need IDbContextFactory for concurrent workloads? A: EF Core's DbContext is not thread-safe by design — it tracks entity state per instance and cannot be shared across concurrent tasks. IDbContextFactory<T> creates an independent context per concurrent flow. Writes from all contexts are serialized at the SQLite level by the write queue.


Benchmark Results

Measured with BenchmarkDotNet v0.14.0 on .NET 10.0.2 / Windows 11 / Intel i7-13700K:

BenchmarkStandard EF CoreThis PackageRatio
BulkInsertOptimizedAsync — 1,000 entities4,500 ms28 ms~161x faster
Concurrent writes (50 tasks, Task.WhenAll)throws SQLITE_BUSYcompletes cleanly

Bulk insert: baseline calls SaveChangesAsync() per entity (1,000 individual transactions); package uses a single batched write. Full numbers and reproduce command: docs/performance-guide.md.

Reproduce: dotnet run -c Release --project EFCore.Sqlite.Concurrency.Benchmarks -- --job short


Documentation


System Requirements

  • .NET 10.0+ (for EF Core APIs) or netstandard 2.0+ (for connection layer only)
  • Entity Framework Core 10.0+
  • Microsoft.Data.Sqlite 10.0+
  • SQLite 3.35.0+ (WAL2 and SQLITE_BUSY_SNAPSHOT require 3.37.0+)

License

MIT License — free for commercial use, open-source projects, and enterprise applications.

About

Fix SQLite Error 5 'database is locked' (SQLITE_BUSY / SQLITE_BUSY_SNAPSHOT) in EF Core. Channel-based write queue, WAL mode, BEGIN IMMEDIATE, exponential retry, and bulk inserts 161x faster.

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages