Enabling SQLite foreign keys and WAL mode with aiosqlite

SQLite disables foreign keys on every new connection, so add a connect event listener that issues PRAGMA foreign_keys = ON — then set journal_mode = WAL once per file and a busy_timeout per connection, and use StaticPool for :memory: databases so the schema survives. This guide belongs to selecting async drivers for SQLite, MySQL and Postgres.

Quick Answer

A test suite on SQLite with default pragmas does not enforce foreign keys, which means constraint bugs pass every test and appear in production.

SQLite defaults are not your defaults Left: foreign keys are not enforced, so a test suite happily inserts an order for a customer that does not exist and a cascade deletes nothing; the rollback journal serialises readers against the writer, so concurrent tests hit database is locked. Right: a connect listener turns foreign keys on for every connection, WAL mode lets readers continue during a write, and a busy timeout waits instead of failing. SQLite defaults foreign_keys = OFF journal_mode = delete constraint violations not caught database is locked under concurrency configured PRAGMA foreign_keys = ON (per connection) PRAGMA journal_mode = WAL (per file) PRAGMA busy_timeout = 5000 tests behave like the real database Foreign keys off by default is the one that quietly invalidates a whole test suite.

Before — defaults, so nothing is enforced:

from sqlalchemy.ext.asyncio import create_async_engine

engine = create_async_engine("sqlite+aiosqlite:///./test.db")

# An order referencing a customer that does not exist is accepted.
# A cascade delete removes nothing, because the foreign key is not enforced.
# Two concurrent writes: sqlite3.OperationalError: database is locked

After — pragmas applied on every connection:

from sqlalchemy import event, text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

engine = create_async_engine(
    "sqlite+aiosqlite:///./test.db",
    connect_args={"timeout": 15},        # seconds the driver waits for a lock
)


@event.listens_for(engine.sync_engine, "connect")
def _sqlite_pragmas(dbapi_connection, connection_record) -> None:
    cursor = dbapi_connection.cursor()
    try:
        cursor.execute("PRAGMA foreign_keys = ON")     # per connection, resets to OFF
        cursor.execute("PRAGMA busy_timeout = 5000")   # per connection, milliseconds
    finally:
        cursor.close()


async def set_wal_mode() -> None:
    """Persistent in the database file: set it once, not per connection."""
    async with engine.begin() as conn:
        await conn.execute(text("PRAGMA journal_mode = WAL"))


Session = async_sessionmaker(engine, expire_on_commit=False)

The listener is registered on engine.sync_engine, because pool and connection events live on the synchronous engine the async engine wraps. Inside it, the cursor calls work through SQLAlchemy's greenlet bridge, so ordinary synchronous code is correct there.

Execution Context & Async Workflow Integration

SQLite's pragmas divide into two kinds, and the division decides where each one belongs.

Per connection, or per file Four steps. foreign_keys is a per-connection setting and resets to off for every new connection, so it belongs in a connect event listener. journal_mode set to WAL is persistent: it is stored in the database file and survives reconnection, so it needs setting once. busy_timeout is per connection. And synchronous is per connection too, and is the one to leave alone unless you understand the durability trade-off. foreign_keys per connection, resets to OFF a connect listener journal_mode = WAL persistent in the file set once busy_timeout per connection a connect listener synchronous per connection Setting WAL on every connect is harmless but pointless; forgetting foreign keys on one is not.

Per-connection pragmas apply to one connection and reset when it closes. foreign_keys is the important one: SQLite has enforced foreign keys since 3.6.19, and defaults to off for backwards compatibility, on every connection. A pooled engine opens several connections over its life, so setting it once after create_async_engine covers one connection and misses the rest — which is why the connect event is the only reliable place. busy_timeout and synchronous are also per connection.

Persistent pragmas are stored in the database file. journal_mode = WAL is the notable one: set it once and every future connection to that file uses WAL, including from other processes. Setting it on every connect is harmless and unnecessary.

WAL mode changes the concurrency story in a way worth understanding, because it is often oversold. In the default rollback-journal mode, a writer blocks readers. In WAL mode, readers continue while a writer is active, which is a genuine improvement. What does not change is that only one writer can hold the database at a time — SQLite serialises writers with a lock, and a second writer either waits (up to busy_timeout) or fails with database is locked.

That single-writer limit is what makes SQLite unable to stand in for PostgreSQL in any test that exercises write concurrency: SELECT ... FOR UPDATE SKIP LOCKED, the job-queue pattern in building a job queue with SELECT FOR UPDATE SKIP LOCKED, optimistic-locking races, and deadlock handling all need a real database.

aiosqlite itself is a thread wrapper around the standard library's sqlite3, so the async API does not make SQLite concurrent — it makes it awaitable. For a test suite that is exactly right: the calls integrate with pytest-asyncio and the same application code runs unchanged, which is the whole reason to use it, as using aiosqlite for async tests and local development describes.

One async-specific detail: because the pragmas are applied in a connect listener, they also apply to connections opened by Alembic during a test-suite migration run — which matters, since a migration that relies on foreign keys being enforced would otherwise behave differently from the application.

Resolving Warnings, Errors & Common Mistakes

Exact error or symptomRoot CauseProduction Fix
Foreign key violations are never raisedforeign_keys defaults to off on every connection.A connect listener issuing PRAGMA foreign_keys = ON.
sqlite3.OperationalError: database is lockedAnother connection holds the write lock; the default timeout is short.busy_timeout, WAL mode, and shorter write transactions.
sqlite3.OperationalError: no such table with :memory:Each new connection gets its own empty in-memory database.poolclass=StaticPool with check_same_thread=False.
ON DELETE CASCADE deletes nothingForeign keys not enforced, so the cascade rule is inert.The same pragma listener.
sqlite3.OperationalError: cannot ALTER TABLE during migrationsSQLite's ALTER TABLE cannot alter or drop columns.Alembic batch mode (op.batch_alter_table).
Timezone-aware datetimes come back naiveSQLite has no timezone-aware timestamp type.Test datetime behaviour against PostgreSQL.
Tests pass on SQLite and fail on PostgreSQLThe test used only portable constructs while the code does not.Move those tests to a PostgreSQL container.
Three pools, three purposes Three tiles. A file database with NullPool opens a connection per checkout, which is simple and fine for tests. A file database with the default pool reuses connections, which needs the pragmas applied on connect. An in-memory database needs StaticPool, because every new connection gets its own empty database and the schema would vanish. file + NullPool a connection per checkout simple, slightly slower file + default pool connections reused pragmas on connect :memory: + StaticPool one shared connection or the schema disappears With :memory: and any other pool, the second connection sees an empty database.

The in-memory case is the one that produces the most confusing error, because the schema appears to be created and then is not there:

from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import StaticPool

engine = create_async_engine(
    "sqlite+aiosqlite:///:memory:",
    poolclass=StaticPool,                       # one connection, shared
    connect_args={"check_same_thread": False},  # aiosqlite uses a worker thread
)

:memory: means "a fresh, private database for this connection". Without StaticPool, the connection that ran create_all() is returned to the pool, a second connection is opened for the first query, and that connection's database is empty. StaticPool keeps exactly one connection for the engine's lifetime, which is what makes an in-memory database usable at all — and also means no concurrency, which is fine for a test.

check_same_thread=False is needed because aiosqlite runs the underlying sqlite3 connection in a worker thread, and sqlite3 refuses cross-thread use by default.

Batch mode is the other thing a SQLite-backed test suite needs from Alembic, because migrations written for PostgreSQL routinely alter columns:

from alembic import op
import sqlalchemy as sa


def upgrade() -> None:
    with op.batch_alter_table("orders") as batch:
        batch.alter_column("total_cents", existing_type=sa.Integer(), type_=sa.BigInteger())

Batch mode recreates the table with the new definition and copies the rows, which SQLite can do. It also needs constraints to be named, which a metadata naming convention provides.

Advanced: A Fixture That Behaves Like the Real Thing

When SQLite is used for tests, the goal is for a test that passes to mean something. That takes three things beyond the pragmas: the same schema-creation path, per-test isolation, and an explicit marker for tests that cannot run there.

Where SQLite stops being a stand-in Four gaps. PostgreSQL-specific types — JSONB operators, ARRAY, TSVECTOR — do not exist. Only one writer can hold the database at a time, even in WAL mode, so write concurrency cannot be tested. ALTER TABLE is limited, so migrations need batch mode. And timezone-aware timestamps are not a native type, so datetime behaviour differs in exactly the way that matters. no JSONB operators, ARRAY or TSVECTOR any test touching those needs a real PostgreSQL one writer at a time, even in WAL write concurrency, SKIP LOCKED and row locking cannot be exercised limited ALTER TABLE Alembic needs batch mode to alter columns no native timezone-aware timestamps datetime round-tripping differs from PostgreSQL in both directions
import pytest
import pytest_asyncio
from sqlalchemy import event, text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool

from shop.models import Base


def _install_pragmas(engine) -> None:
    @event.listens_for(engine.sync_engine, "connect")
    def _pragmas(dbapi_connection, connection_record) -> None:
        cursor = dbapi_connection.cursor()
        try:
            cursor.execute("PRAGMA foreign_keys = ON")
            cursor.execute("PRAGMA busy_timeout = 5000")
        finally:
            cursor.close()


@pytest_asyncio.fixture
async def sqlite_engine():
    engine = create_async_engine(
        "sqlite+aiosqlite:///:memory:",
        poolclass=StaticPool,
        connect_args={"check_same_thread": False},
    )
    _install_pragmas(engine)
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    yield engine
    await engine.dispose()


@pytest_asyncio.fixture
async def sqlite_session(sqlite_engine):
    factory = async_sessionmaker(sqlite_engine, expire_on_commit=False)
    async with factory() as session:
        yield session


@pytest.mark.asyncio
async def test_foreign_keys_are_enforced(sqlite_session):
    from sqlalchemy.exc import IntegrityError

    from shop.models import Order

    sqlite_session.add(Order(customer_id=999_999, total_cents=100))
    with pytest.raises(IntegrityError):
        await sqlite_session.commit()

That last test is worth keeping permanently. It asserts that the pragma listener is installed and working, so a refactor that moves the fixture cannot silently turn foreign-key enforcement off again — which is the failure this whole guide exists to prevent, and one that produces no error when it happens.

A per-test in-memory database gives isolation for free: each test gets a new engine, a new database and a new schema. The cost is create_all() per test, which for a large model is tens of milliseconds. Where that matters, the alternative is a file database created once per session with per-test transaction rollback — the pattern in rolling back database state between async tests.

Note that create_all() builds the schema the models describe, which is not necessarily the schema the migrations produce. For a suite that uses SQLite, that gap is usually accepted deliberately: the migration path is tested against PostgreSQL, where it will actually run.

Deciding What SQLite May Test

The useful question is not "SQLite or PostgreSQL" but "which tests may run on SQLite". Drawing that line explicitly, and enforcing it with markers, is what keeps a fast suite from becoming a misleading one.

Fast, or faithful Left: SQLite starts instantly, needs no service and runs in memory, which makes it attractive for unit tests of code that only uses portable constructs. Right: a PostgreSQL container matches production types, constraints, concurrency and error codes, which is the only way to test the features this site spends most of its time on. SQLite (aiosqlite) instant start-up, no service in-memory, isolated per test portable constructs only good for pure logic PostgreSQL container a few seconds to start real types, real constraints real concurrency and error codes good for everything else Many suites use both, deliberately: the split has to be by what the test exercises, not by speed.

SQLite is suitable for tests of code that uses only portable constructs: business logic over simple selects and inserts, validation, mapping behaviour, cascades (once foreign keys are on), and anything where the database is incidental to the assertion.

SQLite is unsuitable for anything this site's PostgreSQL-specific guidance covers: JSONB and array operators, full-text search, ON CONFLICT upsert semantics, row-level security, SKIP LOCKED, window frames, recursive-CTE cycle handling, timezone-aware timestamp behaviour, and every error code an application branches on.

Making the distinction mechanical means a marker and a fixture per backend:

# conftest.py
import pytest


def pytest_configure(config) -> None:
    config.addinivalue_line("markers", "postgres: requires a real PostgreSQL instance")


# In a test module:
@pytest.mark.postgres
@pytest.mark.asyncio
async def test_jsonb_containment_filter(session, product_factory):
    ...

Running pytest -m "not postgres" then gives the fast suite, and CI runs everything. The marker names the reason, so a reader knows why the test is restricted rather than guessing.

One trap worth naming: a test that happens to pass on SQLite because the feature degraded silently. A JSONB column becomes JSON text on SQLite, so a containment filter may run and return nothing rather than raising, and the test asserting "no results" passes for the wrong reason. Tests should assert the positive case — that the matching row was found — which fails loudly on a backend that cannot do the query.

Finally, be honest about what the fast suite is for. It gives quick feedback on logic during development; it does not tell you the application works. The deployment gate should be the PostgreSQL run, using the container fixture in running tests against a Postgres testcontainer — and if maintaining two backends in the suite starts costing more than the speed is worth, running everything against a container and keeping a warm one locally is a perfectly good answer.

Frequently Asked Questions

Why are foreign keys not enforced in SQLite?

They are disabled by default on every new connection, for backwards compatibility. Enable them with a connect event listener issuing PRAGMA foreign_keys = ON, so every pooled connection gets it.

Does WAL mode make SQLite concurrent?

It lets readers continue while a writer is active, which the default journal mode does not. Only one writer can hold the database at a time either way, so write concurrency still cannot be tested on SQLite.

Why does my in-memory SQLite database lose its tables?

Each new connection to :memory: gets its own empty database. Use poolclass=StaticPool with check_same_thread=False so the engine keeps exactly one connection.

How do I fix "database is locked" in tests?

Set PRAGMA busy_timeout (or the driver timeout), enable WAL mode, and keep write transactions short. If tests genuinely need concurrent writers, they need PostgreSQL.