If you're seeing any of these errors in your .NET application, this page explains what they mean, why they happen, and how EntityFrameworkCore.Sqlite.Concurrency fixes them.
Microsoft.Data.Sqlite.SqliteException: SQLite Error 5: 'database is locked'
What it means: Another connection holds a write lock on the database and your connection
could not acquire one within the busy_timeout window.
Why it happens: SQLite allows only one writer at a time. In a multi-threaded .NET app,
when two threads call SaveChangesAsync() simultaneously, one succeeds and the other
gets SQLITE_BUSY.
Why busy_timeout alone isn't enough: PRAGMA busy_timeout is a per-connection,
SQLite-level retry. It only helps when the conflicting connection is in the same process
and releases the lock within the timeout window. Under sustained concurrent write load,
multiple threads will still contend and produce errors after the timeout expires.
How this package fixes it: A process-wide Channel<IWriteRequest> queues all writes
and drains them one at a time through a single background writer. Callers park asynchronously
and are notified when their write completes — no polling, no wakeup storms, no lock contention.
Microsoft.Data.Sqlite.SqliteException: SQLite Error 5: 'database is locked'
SqliteException.SqliteExtendedErrorCode == 517
What it means: This is SQLITE_BUSY | (2 << 8) = 517. It fires when a connection's
WAL read snapshot became stale after another writer committed mid-transaction. The connection
is trying to upgrade from a read transaction to a write transaction, but its snapshot of
the database is now behind the current WAL head.
Why naive retry doesn't work: Retrying the same statement on the same connection produces the same error — the snapshot is still stale. The only correct fix is to:
- Roll back the entire transaction
- Restart the entire operation from scratch so all reads are re-issued against the current snapshot
How this package fixes it: BEGIN IMMEDIATE is used for all write transactions
(via the UpgradeTransactionsToImmediate option, default true). This acquires the
write lock at transaction start rather than at first write — preventing the snapshot from
ever becoming stale mid-transaction. If SQLITE_BUSY_SNAPSHOT (517) is still received,
ExecuteWithRetryAsync and ExecuteWriteAsync detect the extended error code and restart
the full operation lambda.
Microsoft.Data.Sqlite.SqliteException: SQLite Error 6: 'database table is locked'
What it means: A different table or connection within the same process holds a
lock that conflicts with your operation. Unlike SQLITE_BUSY, this is not a concurrency
issue between processes — it indicates an application-level bug.
Common causes:
- A
DataReaderis open on the same connection while a write is attempted - A transaction was started but not committed or rolled back
- Nested writes without the reentrancy guard
How this package handles it: SQLITE_LOCKED is logged at Error level (not retried)
because it indicates a programming error that retry cannot fix. The exception propagates
to the caller for diagnosis.
If you're still seeing SQLITE_BUSY after installing this package, work through this list:
-
Connection string has
Cache=Shared? Remove it.Cache=Sharedis incompatible with WAL mode and will throwArgumentExceptionat startup if present. Connection pooling (Pooling=true) is enabled automatically. -
Using a shared
DbContextacross concurrent tasks?DbContextis not thread-safe. UseAddConcurrentSqliteDbContextFactory<T>and injectIDbContextFactory<T>. CallCreateDbContext()per concurrent operation. See Concurrent EF Core patterns. -
Multiple processes writing to the same database? The write queue is process-scoped. Multiple OS processes contend at the SQLite level. Increase
BusyTimeoutandMaxRetryAttempts, or consider a client/server database. -
Database on a network filesystem? WAL mode does not work on NFS, SMB, or other network-mounted paths. Use local disk only.
-
WAL file growing unboundedly? A long-running read transaction can block checkpoint completion, causing the WAL to grow and degrade performance. Check WAL health:
var connection = db.Database.GetDbConnection();
await connection.OpenAsync();
var status = await SqliteConnectionEnhancer.GetWalCheckpointStatusAsync(connection);
if (status.IsBusy)
Console.WriteLine($"WAL blocked: {status.TotalWalFrames} frames, " +
$"{status.CheckpointedFrames} checkpointed " +
$"({status.CheckpointProgress:F1}%)");| Code | Extended Code | Name | Meaning | Retry? |
|---|---|---|---|---|
| 5 | 5 | SQLITE_BUSY |
Another writer holds the lock | Yes — after backoff |
| 5 | 261 | SQLITE_BUSY_RECOVERY |
WAL recovery in progress | Yes — after backoff |
| 5 | 517 | SQLITE_BUSY_SNAPSHOT |
Read snapshot is stale | Yes — restart entire operation |
| 6 | 6 | SQLITE_LOCKED |
Same-connection conflict | No — application bug |