Fix: aiosqlite Not Working — Single Writer, WAL Mode, Row Factory, and Connection Patterns
Part of: Python Errors
Quick Answer
How to fix Python aiosqlite errors — database is locked, WAL mode for concurrent reads, foreign_keys PRAGMA, row factory for dict-like rows, connection per request vs pool, datetime detect_types, and FastAPI integration.
The Error
You hit database is locked under any concurrent write:
async with aiosqlite.connect("app.db") as db:
await db.execute("INSERT INTO posts (title) VALUES (?)", ("hi",))
await db.commit()
# Two requests in flight → second one hangs or errors.Or rows come back as tuples instead of dicts:
async with aiosqlite.connect("app.db") as db:
async with db.execute("SELECT id, name FROM users") as cursor:
async for row in cursor:
print(row[0], row[1]) # No row["name"]Or foreign keys are silently not enforced:
await db.execute(
"INSERT INTO posts (user_id) VALUES (?)",
(99999,), # nonexistent user
)
await db.commit() # Succeeds! Foreign key not checked.Or datetime fields return as strings:
async with db.execute("SELECT created_at FROM posts LIMIT 1") as cur:
row = await cur.fetchone()
print(type(row[0])) # <class 'str'>, not datetimeWhy This Happens
aiosqlite is an asyncio wrapper around Python’s sqlite3. Underneath, it runs SQLite in a background thread per connection and uses callbacks to bridge to asyncio. SQLite itself has limits:
- Single-writer. SQLite allows multiple readers but only one writer at a time. With
journal_mode=DELETE(default), writers also block readers. WAL mode fixes most of this. - No automatic foreign key checks. PRAGMA
foreign_keysis off by default per connection. - No native datetime. SQLite stores dates as text/int. Python’s
sqlite3can auto-convert withdetect_types. - Connections are stateful. PRAGMA settings, transactions, prepared statements — all per-connection. Reusing connections across requests requires care.
aiosqlite adds asyncio compatibility but doesn’t fix SQLite’s underlying limitations.
The architecture matters when debugging slow queries. Each aiosqlite.connect() spawns a dedicated worker thread that owns one C-level sqlite3 connection. Coroutines on the asyncio side await futures whose results are produced by that thread. A single connection therefore serializes all of its operations, even though they look concurrent in await syntax. Two await db.execute(...) calls on the same connection never actually run in parallel — they queue inside the worker thread.
The other quiet trap is the asyncio backend assumption. aiosqlite is built on asyncio.get_event_loop(). It does not natively support trio or anyio’s trio backend. If you embed an aiosqlite call inside a trio task without an anyio wrapper, the future never resolves and the call hangs forever with no error. Pick an asyncio-only stack, or use anyio.from_thread.run_sync with a vanilla sqlite3 connection.
Diagnostic Timeline
A typical database is locked debugging session.
Minute 0. Tests pass; production throws database is locked under load. First guess: “use sqlite3 instead of aiosqlite.” The async wrapper is not the problem — SQLite itself is single-writer and your default journal_mode is DELETE, which blocks readers behind writers.
Minute 5. You add PRAGMA journal_mode = WAL inside the request handler. Same error. WAL mode is a database-level setting, not per-connection — and the migration that runs at startup did not persist it because it ran on an in-memory connection. You move the pragma to a startup script that runs against the on-disk file.
Minute 13. Locks disappear under read load but still bite during bulk inserts. First guess: “use BEGIN.” Adding BEGIN does nothing because each statement was already inside an implicit transaction. The fix is BEGIN IMMEDIATE so SQLite acquires the write lock upfront instead of upgrading from a read lock mid-transaction.
Minute 22. You add a connection pool and now requests crash with Recursive use of cursor not allowed. The pool gives different requests the same connection — one is mid-cursor when another tries to reuse it. The fix is making each acquire/release strictly bracket the entire query, including fetchall().
Minute 31. Datetime columns come back as strings on Python 3.12. First guess: “missing detect_types.” You add detect_types=PARSE_DECLTYPES. Still strings. The real cause: 3.12 deprecated the built-in adapter and emits a DeprecationWarning. Register explicit register_adapter/register_converter for datetime and date.
Minute 40. Switching from asyncio to trio for new code: aiosqlite calls hang silently. The aiosqlite worker thread is waiting on an asyncio future that trio never schedules. You either keep aiosqlite on the asyncio side via anyio interop, or drop down to sqlite3 inside anyio.to_thread.run_sync.
Fix 1: Enable WAL Mode
WAL (Write-Ahead Logging) lets readers proceed during writes:
import aiosqlite
async def setup_db():
async with aiosqlite.connect("app.db") as db:
await db.execute("PRAGMA journal_mode = WAL")
await db.execute("PRAGMA synchronous = NORMAL")
await db.commit()PRAGMA journal_mode = WAL sticks — it’s a database-file property, not per-connection. Run once during setup; all future connections use WAL.
PRAGMA synchronous = NORMAL trades a tiny crash-safety margin for big throughput gains. For most apps, the trade is worth it.
Other useful pragmas:
await db.execute("PRAGMA foreign_keys = ON") # Per-connection
await db.execute("PRAGMA busy_timeout = 5000") # 5 seconds before locked
await db.execute("PRAGMA temp_store = MEMORY") # Faster temp tables
await db.execute("PRAGMA cache_size = -64000") # 64 MB cachebusy_timeout is critical for “database is locked” — instead of failing immediately, SQLite waits this long for the lock.
Pro Tip: Use a single setup function called once at startup that opens the DB, sets pragmas, runs migrations. Subsequent connections inherit WAL (it’s persistent) but still need per-connection foreign_keys.
Fix 2: Connection Patterns — Per-Request vs Shared
For asyncio web apps (FastAPI/Starlette), there are three patterns:
Pattern A — connection per request (simple, safe):
from fastapi import FastAPI, Depends
import aiosqlite
app = FastAPI()
async def get_db():
async with aiosqlite.connect("app.db") as db:
await db.execute("PRAGMA foreign_keys = ON")
db.row_factory = aiosqlite.Row
yield db
@app.get("/users/{user_id}")
async def get_user(user_id: int, db: aiosqlite.Connection = Depends(get_db)):
async with db.execute("SELECT * FROM users WHERE id = ?", (user_id,)) as cur:
row = await cur.fetchone()
return dict(row) if row else NonePros: simple, per-request transaction isolation. Cons: connection setup overhead per request (~1-5 ms).
Pattern B — shared connection (FAST but care needed):
import aiosqlite
from contextlib import asynccontextmanager
from fastapi import FastAPI
db: aiosqlite.Connection | None = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global db
db = await aiosqlite.connect("app.db")
await db.execute("PRAGMA journal_mode = WAL")
await db.execute("PRAGMA foreign_keys = ON")
db.row_factory = aiosqlite.Row
yield
await db.close()
app = FastAPI(lifespan=lifespan)
@app.get("/users/{user_id}")
async def get_user(user_id: int):
async with db.execute("SELECT * FROM users WHERE id = ?", (user_id,)) as cur:
row = await cur.fetchone()
return dict(row) if row else NonePros: zero connection overhead, fastest. Cons: single connection serializes all queries — you lose concurrency.
Pattern C — pool (best for high concurrency):
# aiosqlite doesn't include a built-in pool; use a small one yourself:
import asyncio
import aiosqlite
class SqlitePool:
def __init__(self, db_path: str, size: int = 5):
self.db_path = db_path
self.size = size
self._pool = asyncio.Queue(maxsize=size)
async def init(self):
for _ in range(self.size):
conn = await aiosqlite.connect(self.db_path)
await conn.execute("PRAGMA foreign_keys = ON")
conn.row_factory = aiosqlite.Row
await self._pool.put(conn)
async def acquire(self):
return await self._pool.get()
async def release(self, conn):
await self._pool.put(conn)
async def close(self):
while not self._pool.empty():
conn = await self._pool.get()
await conn.close()
pool = SqlitePool("app.db", size=5)
await pool.init()
# Per request:
conn = await pool.acquire()
try:
async with conn.execute("SELECT * FROM users") as cur:
rows = await cur.fetchall()
finally:
await pool.release(conn)A pool lets N concurrent queries run, capped at N connections to SQLite.
Pro Tip: For most apps, Pattern A is fine — connection overhead is small compared to query time. Switch to Pattern B/C only if profiling shows connection setup as a bottleneck.
Fix 3: Row Factory for Dict-Like Access
async with aiosqlite.connect("app.db") as db:
db.row_factory = aiosqlite.Row
async with db.execute("SELECT id, name FROM users WHERE id = ?", (1,)) as cur:
row = await cur.fetchone()
if row:
print(row["name"]) # Dict-like access
print(row[0]) # Index access still works
print(dict(row)) # Convert to plain dictaiosqlite.Row (same as sqlite3.Row) is a tuple-like object with named-field access. Setting row_factory is per-connection.
For Pydantic models:
from pydantic import BaseModel
class User(BaseModel):
id: int
name: str
async with db.execute("SELECT id, name FROM users WHERE id = ?", (1,)) as cur:
row = await cur.fetchone()
if row:
user = User(**dict(row))For mass conversion:
async with db.execute("SELECT id, name FROM users") as cur:
rows = await cur.fetchall()
users = [User(**dict(row)) for row in rows]Common Mistake: Forgetting row_factory = aiosqlite.Row per connection. The default returns plain tuples. Set it in your connection setup function.
Fix 4: Datetime Handling
SQLite has no native datetime — values stored as text/int. To convert in Python:
import aiosqlite
import sqlite3
# Detect types from column declarations or queries:
db = await aiosqlite.connect(
"app.db",
detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES,
)await db.execute("""
CREATE TABLE posts (
id INTEGER PRIMARY KEY,
title TEXT,
created_at TIMESTAMP
)
""")With PARSE_DECLTYPES, the TIMESTAMP column type triggers automatic datetime conversion.
For column aliases:
async with db.execute('SELECT created_at AS "ts [timestamp]" FROM posts') as cur:
row = await cur.fetchone()
print(type(row[0])) # datetimeThe [timestamp] suffix on the alias is the explicit type hint for PARSE_COLNAMES.
For explicit conversion without detect_types:
from datetime import datetime
async def fetch_posts():
async with db.execute("SELECT id, created_at FROM posts") as cur:
rows = await cur.fetchall()
return [
{"id": r[0], "created_at": datetime.fromisoformat(r[1])}
for r in rows
]Pro Tip: Recent Python (3.12+) deprecated the default datetime adapter. Use register_adapter + register_converter for explicit control:
import sqlite3
from datetime import datetime, date
sqlite3.register_adapter(datetime, lambda d: d.isoformat())
sqlite3.register_adapter(date, lambda d: d.isoformat())
sqlite3.register_converter("timestamp", lambda b: datetime.fromisoformat(b.decode()))
sqlite3.register_converter("date", lambda b: date.fromisoformat(b.decode()))These run for all sqlite3 (and aiosqlite) connections.
Fix 5: Foreign Keys
Enable on every connection:
await db.execute("PRAGMA foreign_keys = ON")This is per-connection. Doing it once at app startup is not enough — every new connection needs it.
To verify:
async with db.execute("PRAGMA foreign_keys") as cur:
row = await cur.fetchone()
print(row[0]) # 1 = on, 0 = offFor schemas with foreign key constraints to be enforced, this must be ON. Schema definition itself is correct without the PRAGMA — it just doesn’t enforce.
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE posts (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT
);REFERENCES users(id) ON DELETE CASCADE — with foreign_keys = ON, deleting a user deletes their posts. Without it, the user deletes but posts orphan.
Fix 6: Transactions and BEGIN
aiosqlite (like sqlite3) starts implicit transactions on writes. To control explicitly:
async with aiosqlite.connect("app.db") as db:
await db.execute("BEGIN")
try:
await db.execute("UPDATE accounts SET balance = balance - 100 WHERE id = 1")
await db.execute("UPDATE accounts SET balance = balance + 100 WHERE id = 2")
await db.execute("COMMIT")
except Exception:
await db.execute("ROLLBACK")
raiseOr use isolation_level=None for autocommit:
async with aiosqlite.connect("app.db", isolation_level=None) as db:
await db.execute("INSERT INTO logs VALUES (?)", ("event",))
# Auto-committed.For repeated writes that need batching:
async with aiosqlite.connect("app.db") as db:
await db.execute("BEGIN IMMEDIATE") # Acquire write lock now
for record in many_records:
await db.execute("INSERT INTO ...", record)
await db.commit()BEGIN IMMEDIATE reserves the write lock right away — preventing “database is locked” mid-batch.
Common Mistake: Long-running transactions block all other writers. SQLite is single-writer; a transaction held for 30 seconds blocks every other write for 30 seconds. Keep transactions short.
Fix 7: Concurrent Reads
WAL mode lets unlimited readers run concurrently with one writer:
async def read_many(ids):
async with aiosqlite.connect("app.db") as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
f"SELECT * FROM users WHERE id IN ({','.join('?' for _ in ids)})",
ids,
)
return [dict(r) for r in await cur.fetchall()]
# Many concurrent reads:
results = await asyncio.gather(
read_many([1, 2, 3]),
read_many([4, 5, 6]),
read_many([7, 8, 9]),
)In WAL mode, all three reads proceed in parallel against snapshots — no blocking.
For sustained read throughput, use a connection pool (Fix 2 Pattern C).
Pro Tip: Periodic PRAGMA wal_checkpoint(TRUNCATE) keeps the WAL file from growing without bound. SQLite checkpoints automatically but you can force it during quiet periods.
Fix 8: Migrations
aiosqlite has no migration framework. For simple cases, use Alembic with SQLAlchemy + aiosqlite driver:
pip install alembic sqlalchemy[asyncio] aiosqlitealembic.ini:
sqlalchemy.url = sqlite+aiosqlite:///app.dbFor just running a versioned schema:
async def migrate(db: aiosqlite.Connection):
await db.execute("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER)")
async with db.execute("SELECT version FROM schema_version") as cur:
row = await cur.fetchone()
current = row[0] if row else 0
migrations = [
(1, "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"),
(2, "ALTER TABLE users ADD COLUMN email TEXT"),
(3, "CREATE INDEX idx_users_email ON users(email)"),
]
for version, sql in migrations:
if version > current:
await db.execute(sql)
if current == 0:
await db.execute("INSERT INTO schema_version VALUES (?)", (version,))
else:
await db.execute("UPDATE schema_version SET version = ?", (version,))
await db.commit()For more complex schema changes (table rename, type change), SQLite often requires the “create new table, copy data, drop old, rename” pattern. Alembic handles this automatically.
Still Not Working?
A few less-obvious failures:
database is lockeddespite WAL. Another process opened the file in journal mode and broke WAL. Reset:PRAGMA journal_mode = WAL.- Slow writes after enabling foreign keys. Foreign key checks add overhead. For bulk loads, temporarily disable:
PRAGMA foreign_keys = OFF; ...; PRAGMA foreign_keys = ON;and verify integrity afterward. Connection closedmid-request. A timeout killed the connection. Increaseaiosqlite.connect(timeout=10)— default is 5 seconds for getting a lock.Recursive use of cursor not allowed. Two queries on the same cursor. Use separateasync with db.execute(...)blocks.- High memory with big result sets.
fetchall()loads everything into memory. Iterate instead:async for row in cur:reads one at a time. - Pickle errors with custom types. SQLite stores limited types. Convert custom objects to JSON/string before insert.
- Memory database (
:memory:) shared across connections. Not by default — each:memory:connection is its own DB. Usefile::memory:?cache=sharedURI for shared in-memory. asyncioevent loop closed errors at shutdown. aiosqlite uses a background thread. Alwaysawait db.close()before exiting; in FastAPI, do so in thelifespanshutdown.- WAL file grows unbounded in production. The auto-checkpoint runs only when a connection commits and the WAL exceeds 1000 pages. A long-lived read connection holding a snapshot blocks checkpoints. Periodically open a fresh connection and run
PRAGMA wal_checkpoint(TRUNCATE). - Database file copied via
cpwhile the app is running is corrupt. SQLite’s WAL means the.dbfile alone is not consistent — you also need the-waland-shmfiles, or use the online backup API viasqlite3.Connection.backup(). uvloopplus aiosqlite races on shutdown. uvloop’s faster signal handling sometimes drops the worker thread’s join callback. Use the asyncio default loop policy in environments where you tear down connections at SIGTERM, or setloop_factory=asyncio.new_event_loop.
For related Python async DB and SQLite issues, see SQLite database is locked, SQLAlchemy not working, Python asyncio not running, and FastAPI dependency injection error.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: arq Not Working — Worker Not Picking Jobs, WorkerSettings, Cron, Retries, and Result Expiry
How to fix Python arq errors — worker can't find tasks, WorkerSettings class structure, cron syntax differences, msgpack serialization errors, job_id deduplication, result expiration, and Redis connection pooling.
Fix: Tortoise ORM Not Working — Model Registration, Async Init, and Relationship Errors
How to fix Tortoise ORM errors — Tortoise.init not called, no module imported model, fetch_related missing, aerich migration setup, FastAPI integration patterns, and ConfigurationError missing connection.
Fix: httpx Not Working — Async Client, Timeout, and Connection Pool Errors
How to fix httpx errors — RuntimeError event loop is closed, ReadTimeout exception, ConnectionResetError, async client not closing properly, HTTP/2 not enabled, SSL verify failed, and proxy not working.
Fix: Python asyncio Blocking the Event Loop — Mixing Sync and Async Code
How to fix Python asyncio event loop blocking — using run_in_executor for sync calls, asyncio.to_thread, avoiding blocking I/O in coroutines, and detecting event loop stalls.