Using SQLAlchemy Async with Celery Task Workers

Use asyncio.run() inside each synchronous Celery task to create an isolated event loop per task invocation, and combine that with either NullPool or a per-process engine (created after os.fork()) to avoid asyncpg connection-state corruption. This how-to sits under the asyncpg vs psycopg3 driver guide and assumes you have already chosen a driver and set up async engine and connection pooling for your application — the wrinkle here is that Celery's prefork pool copies that engine across a fork() boundary where async connections do not survive.

Quick Answer

The minimal working pattern: create the engine after forking (in worker_process_init), use NullPool to prevent pool state crossing fork boundaries, wrap async DB work in asyncio.run() inside standard @app.task functions.

The fork is what breaks the engine, not Celery Two module shapes. On the left, create_async_engine is called at import time, so the engine and its open sockets exist before the prefork worker forks. Every child process inherits the same file descriptors, two children can write to one socket concurrently, and the symptoms are corrupted protocol state and InterfaceError raised at random. On the right, the engine is created inside a worker_process_init signal handler, which runs after the fork in each child, so every worker owns a private pool and no descriptor is ever shared. engine created at import time engine = create_async_engine(URL) # module level the prefork worker forks after this runs every child inherits the same sockets InterfaceError, corrupted protocol state engine created after the fork @worker_process_init.connect def init(**_): engine = create_async_engine(URL) each child builds its own pool no descriptor is ever shared between processes The same rule applies to any pre-fork server — Gunicorn sync workers included. If you cannot move engine creation, call engine.dispose() in the child before first use.
# Legacy / incorrect approach — engine created at module import time,
# before Celery forks worker processes. asyncpg connections are not fork-safe.

# BAD: module-level engine creation
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession

engine = create_async_engine("postgresql+asyncpg://user:pass@db:5432/prod")
SessionFactory = async_sessionmaker(engine, class_=AsyncSession)

# Correct approach — NullPool + post-fork engine initialisation
import asyncio
import os
from celery import Celery
from celery.signals import worker_process_init
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.pool import NullPool

app = Celery("tasks", broker="redis://localhost:6379/0")

_engine = None
_SessionFactory = None


@worker_process_init.connect
def init_db_engine(**kwargs):
    """
    Runs once in each worker process AFTER os.fork().
    NullPool ensures no connections are shared across processes.
    """
    global _engine, _SessionFactory
    _engine = create_async_engine(
        os.environ["DATABASE_URL"],   # postgresql+asyncpg://...
        poolclass=NullPool,           # no persistent pool — each task gets a fresh connection
        echo=False,
    )
    _SessionFactory = async_sessionmaker(
        _engine,
        class_=AsyncSession,
        expire_on_commit=False,
    )


@app.task(bind=True, max_retries=3)
def process_invoice(self, invoice_id: int) -> dict:
    async def _run() -> dict:
        async with _SessionFactory() as session:
            async with session.begin():
                from sqlalchemy import select
                from myapp.models import Invoice
                result = await session.execute(
                    select(Invoice).where(Invoice.id == invoice_id)
                )
                invoice = result.scalar_one_or_none()
                if invoice is None:
                    return {"status": "not_found", "invoice_id": invoice_id}
                invoice.status = "processed"
                return {"status": "ok", "reference": invoice.reference}

    try:
        return asyncio.run(_run())
    except Exception as exc:
        raise self.retry(exc=exc, countdown=30)

Execution Context & Async Workflow Integration

Celery's default worker pool (prefork) creates worker processes by calling os.fork() on the parent process. Any resource that was open in the parent — file descriptors, asyncpg TCP connections, internal asyncio state — is duplicated into the child with shared but diverged state. asyncpg's connections are not safe after fork() because the underlying TCP socket is now owned by two processes, and the asyncpg protocol state machine expects to be the sole reader/writer.

Fork-safe engine initialisation in Celery prefork workers A parent Celery process created a module-level async engine with an open asyncpg socket before os.fork(), which is not fork-safe. On the naive path, Worker A and Worker B both inherit and reuse the same copied socket file descriptor, so concurrent operations collide and raise asyncpg InterfaceError, another operation is in progress. On the correct path, worker_process_init rebuilds the engine after the fork with poolclass=NullPool, each @app.task runs asyncio.run() with a fresh event loop, and every invocation opens, queries, and closes exactly one connection that is torn down before the task returns. Parent Celery process (before fork) module-level engine · open asyncpg socket UNSAFE os.fork() Naive — child keeps the inherited engine Correct — rebuild the engine after fork Worker A reuses parent engine Worker B reuses parent engine one copied asyncpg socket same fd owned by both processes asyncpg.InterfaceError another operation is in progress worker_process_init.connect create_async_engine(..., poolclass=NullPool) @app.task → asyncio.run(_run()) fresh event loop per invocation open → query → close one connection, torn down before the task returns
Why a module-level engine breaks under Celery's prefork pool, and how deferring engine creation to worker_process_init with NullPool fixes it.

Why asyncio.run() per task is correct

asyncio.run() creates a brand-new event loop, runs the coroutine to completion, then destroys the loop. This is exactly what you need for a Celery task:

  • No loop state leaks between tasks (no lingering callbacks, handles, or pending futures).
  • The event loop is garbage-collected after the task returns, releasing asyncio-internal resources.
  • asyncpg connections acquired during the task are closed when the session context exits, before asyncio.run() returns — the AsyncSession lifecycle governs exactly when the connection is released, and NullPool turns that release into a real socket close.

The alternative — sharing a persistent event loop across tasks in the same worker process — creates subtle race conditions when two tasks run concurrently in an async Celery setup and both try to schedule coroutines onto the same loop from different threads.

NullPool vs QueuePool in worker processes

Pool classBehaviour in prefork workersWhen to use
NullPoolOpens a connection per async with session:, closes it immediately on exit. No idle connections held.Always use with prefork Celery workers. Avoids fork-safety issues.
AsyncAdaptedQueuePool (default)Keeps pool_size connections open indefinitely. Not safe after os.fork().ASGI servers (FastAPI, Starlette), not Celery prefork.
StaticPoolSingle reused connection — never appropriate for multi-process workers.In-memory SQLite test fixtures only.

NullPool does mean a TCP handshake and TLS negotiation per task if the database is remote. For tasks that run many short queries, batch them into a single task invocation rather than firing one task per row. If task throughput is high and connection overhead is measurable, deploy PgBouncer in front of Postgres — PgBouncer's connection pool amortises the handshake cost, and you use NullPool on the SQLAlchemy side (the SQLAlchemy pool is redundant behind a pooler anyway). Note that transaction-mode PgBouncer forces you to disable asyncpg's prepared-statement cache; the guide on handling asyncpg prepared-statement errors with PgBouncer covers the exact connect_args you need.

Celery native async tasks (Celery 5.x)

Celery 5.1+ supports async def task functions natively when the worker runs under the gevent or eventlet pools, or when using the experimental asyncio pool. For most production deployments the prefork pool remains the default, so asyncio.run() in sync tasks is the recommended pattern. If you adopt Celery's async execution mode:

import asyncio
from celery import Celery
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from myapp.db import get_session_factory
from myapp.models import Order

app = Celery("tasks", broker="redis://localhost:6379/0")


@app.task
async def fulfill_order(order_id: int) -> str:
    """
    Native async Celery task (requires asyncio-compatible worker pool).
    The task coroutine runs inside the worker's event loop directly —
    no asyncio.run() wrapper needed.
    """
    SessionFactory = get_session_factory()   # must be created post-fork
    async with SessionFactory() as session:
        async with session.begin():
            result = await session.execute(
                select(Order).where(Order.id == order_id)
            )
            order = result.scalar_one()
            order.fulfillment_status = "dispatched"
    return f"Order {order_id} fulfilled"

Resolving Warnings, Errors & Common Mistakes

Error / WarningRoot CauseProduction Fix
asyncpg.exceptions.InterfaceError: cannot perform operation: another operation is in progressTwo coroutines sharing the same asyncpg connection concurrently (e.g., shared engine created before fork, used in multiple tasks).Create engine per-worker in worker_process_init with NullPool.
RuntimeError: no running event loopasyncio.get_event_loop() called inside a sync task where no loop exists (Python 3.10+ deprecated the implicit loop).Replace with asyncio.run(coroutine()) — creates its own loop.
GreenletSpawnError: greenlet context switch not allowedSync SQLAlchemy ORM attribute access (lazy load) triggered inside an async context without a running greenlet (full diagnosis).Use expire_on_commit=False and eager-load all relationships with selectinload() before the session closes.
QueuePool limit of size N overflow N reached, connection timed outpool_size set on a pool that also has to serve multiple concurrent tasks, or engine created before fork so the pool is shared (and corrupted).Use NullPool — removes pool contention entirely for prefork workers.
sqlalchemy.exc.TimeoutError on pool.connect() after task retry stormMany tasks retrying simultaneously all request connections; pool exhausted.With NullPool, pool exhaustion is impossible (no pool). Switch to NullPool; if using PgBouncer, increase pool_size on the PgBouncer side.
asyncpg.exceptions.InvalidSQLStatementNameError: prepared statement "..." does not existasyncpg's prepared-statement cache hit a PgBouncer-recycled connection where the server-side statement was discarded.Add connect_args={"statement_cache_size": 0} to the engine when Postgres is behind PgBouncer in transaction mode.
psycopg.OperationalError: the connection is closedWorker process inherited an open psycopg3 connection from the parent; the forked file descriptor is in an undefined state.Same fix as asyncpg: defer engine creation to worker_process_init.
DetachedInstanceError: Instance <User> is not bound to a SessionORM instance accessed outside the async with session: block (lazy relationship traversal after session close — see fixing DetachedInstanceError after commit).Set expire_on_commit=False and load all needed attributes/relationships before the session closes.

Advanced Celery + Async SQLAlchemy Optimisation

Batching DB operations to amortise connection overhead

With NullPool, every task opens and closes a connection. If you have thousands of small tasks, the connection overhead dominates. Redesign task granularity to process batches:

Three ways to bridge a sync task to an async engine Three options. Calling asyncio.run inside each task is the simplest and is correct, but it builds and tears down an event loop per task, and because the engine outlives the loop its pooled connections are bound to a loop that no longer exists — so the pool must be disposed or the engine created per loop. Running one long-lived loop per worker process in a background thread, and submitting coroutines to it, keeps the pool valid across tasks and is the best-performing option, at the cost of real complexity. Using a sync engine in the worker while the API stays async is the least glamorous option and frequently the right one: Celery tasks are already running in their own process, so the async advantage is small. asyncio.run() per task simplest, and correct a new loop per task the pool must not outlive the loop one loop per worker process submit coroutines to it pool stays valid across tasks fastest, and the most code a sync engine in the worker async API, sync worker no bridging at all often the right answer The third column loses less than teams expect: a Celery task already owns its process, so concurrency comes from process count rather than from the event loop.
import asyncio
from celery import Celery
from celery.signals import worker_process_init
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.pool import NullPool
from myapp.models import User
import os

app = Celery("tasks", broker="redis://localhost:6379/0")

_SessionFactory = None


@worker_process_init.connect
def init_engine(**kwargs):
    global _SessionFactory
    engine = create_async_engine(
        os.environ["DATABASE_URL"],
        poolclass=NullPool,
    )
    _SessionFactory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)


@app.task
def send_digest_emails(user_ids: list[int]) -> dict:
    """
    Processes a batch of users in a single DB round-trip instead of
    one task per user. Single connection open/close for the entire batch.
    """
    async def _run() -> dict:
        async with _SessionFactory() as session:
            async with session.begin():
                result = await session.execute(
                    select(User)
                    .where(User.id.in_(user_ids))
                    .where(User.email_confirmed.is_(True))
                )
                users = result.scalars().all()

                processed = []
                for user in users:
                    # ... send email via external service ...
                    processed.append(user.id)

                # Bulk-update in one statement
                await session.execute(
                    update(User)
                    .where(User.id.in_(processed))
                    .values(last_digest_sent_at=__import__("datetime").datetime.utcnow())
                )
                return {"processed": len(processed), "batch_size": len(user_ids)}

    return asyncio.run(_run())

Monitoring connection health in Celery workers

Because NullPool opens fresh connections per task, traditional pool-level metrics (QueuePool.checkedin, checkedout) are not available. Instead, instrument at the query level:

from sqlalchemy import event
from sqlalchemy.engine import Engine
import time
import logging

logger = logging.getLogger(__name__)


def instrument_engine(engine):
    """Attach timing hooks to an async engine for per-task latency logging."""

    @event.listens_for(engine.sync_engine, "before_cursor_execute")
    def before_execute(conn, cursor, statement, parameters, context, executemany):
        conn.info["query_start"] = time.perf_counter()

    @event.listens_for(engine.sync_engine, "after_cursor_execute")
    def after_execute(conn, cursor, statement, parameters, context, executemany):
        elapsed = time.perf_counter() - conn.info.get("query_start", time.perf_counter())
        logger.debug("Query completed in %.3fs: %.120s", elapsed, statement)

Frequently Asked Questions

Can I use a persistent QueuePool inside Celery workers instead of NullPool?

Only if you are certain the engine is created after os.fork() (via worker_process_init) and you are not sharing the engine object across processes. Even then, asyncpg connections have internal asyncio state tied to a specific event loop. Because asyncio.run() creates a new loop per task, and asyncpg connections cache a reference to the loop they were created on, a pooled connection from a previous task's asyncio.run() invocation will raise RuntimeError: loop is closed when the next task tries to reuse it. NullPool eliminates this entirely by never caching connections between tasks.

How do I pass the database URL to workers without hardcoding it?

Use Celery's worker_process_init signal with os.environ["DATABASE_URL"]. Set the variable via your process supervisor (systemd, Docker ENV, Kubernetes envFrom). Never embed credentials in the Celery broker URL or task arguments.

Does psycopg3 have the same fork-safety problems as asyncpg in prefork workers?

Yes. Both asyncpg and psycopg3 async connections are tied to the asyncio event loop that created them, and that loop's internal state is duplicated — not shared cleanly — across os.fork(). The fix is identical for both: defer engine creation to worker_process_init and use NullPool.

What happens if a Celery task times out (SoftTimeLimitExceeded) mid-await?

Celery raises celery.exceptions.SoftTimeLimitExceeded (a BaseException subclass) as a signal. If this interrupts an await session.execute() call, the async with session.begin(): context manager's __aexit__ may not run, leaving the connection in an indeterminate state. With NullPool, the underlying TCP connection is simply abandoned and eventually cleaned up by the OS. Wrap the outermost asyncio.run() in a try/except SoftTimeLimitExceeded block that explicitly calls asyncio.run(session.close()) if you need deterministic cleanup.