Skip to content

Async ORM

EndoCore runs under ASGI, where blocking the event loop hurts everyone. The ORM provides an async API — by default it runs the (battle-tested) sync ORM in a threadpool via asyncio.to_thread, so your handlers stay non-blocking on both SQLite and PostgreSQL. On PostgreSQL you can opt into a fully driver-native async path instead — see Native async on PostgreSQL below.

Why a threadpool by default, instead of native async drivers

The threadpool offload gives non-blocking access over the exact same query engine, for both dialects, with zero duplicated code paths — and it's what every async method below uses unless you opt into async_native=True. Each alias owns a small connection pool (configure(..., pool_size=N); SQLite 1, PostgreSQL 5) and SQLite connections are opened with check_same_thread=False, so cross-thread use is safe.

Manager & QuerySet

Every common terminal operation has an a-prefixed async twin:

# create / read
user = await User.objects.acreate(name="Ada", age=36)
user = await User.objects.aget(name="Ada")
n    = await User.objects.acount()
ok   = await User.objects.filter(active=True).aexists()
first = await User.objects.order_by("age").afirst()
last  = await User.objects.order_by("age").alast()
rows  = await User.objects.filter(age__gte=18).alist()

# async iteration
async for user in User.objects.order_by("name"):
    ...

# write
await User.objects.all().aupdate(active=True)
await User.objects.filter(spam=True).adelete()
await User.objects.abulk_create([User(name="a"), User(name="b")])
await User.objects.abulk_update(users, ["age"])

# helpers
user, created = await User.objects.aget_or_create(name="Ada", defaults={"age": 36})
user, created = await User.objects.aupdate_or_create(name="Ada", defaults={"age": 37})
by_pk = await User.objects.ain_bulk([1, 2, 3])
stats = await User.objects.aaggregate(total=Count("*"))

Instances

user = await User.objects.aget(pk=1)
user.age += 1
await user.asave()
await user.arefresh_from_db()
await user.adelete()

What stays synchronous

Not every ORM operation has an async counterpart:

  • Building a QuerySetfilter(), exclude(), order_by(), values(), … never touch the database, so there's nothing to make async; only the terminal call that actually evaluates the query (aget, alist, …) hits it.
  • ManyRelatedManager.add()/.remove()/.set()/.clear() on a ManyToManyField have no async form yet, sync or native. Offload them like any other sync call made from async code: await asyncio.to_thread(book.tags.add, tag).

In a handler

from endocore import Request, Response
from Models.blog import Post

async def handler(request: Request) -> Response:      # Api/v1/Post/Get.py
    posts = await Post.objects.order_by("-id").alist()
    return Response.json({"posts": [p.title for p in posts]})

Transactions

Use async with aatomic():a* calls inside the block join the transaction (ownership is a contextvars token that asyncio.to_thread propagates), and the pooled connection is acquired off-loop, so waiting on an exhausted pool can't block the event loop. See Transactions.

Notes

  • Manager exposes async read/create helpers; aupdate/adelete live on the QuerySet (call .all().aupdate(...)), matching Django's sync semantics.
  • Building a query (filter, order_by, …) is cheap and stays sync; only the evaluation is offloaded.
  • Under heavy concurrency, tune the default threadpool (anyio/asyncio executor) or run multiple workers.

Native async on PostgreSQL

endocore[postgres] already pins psycopg[binary]>=3.1 — psycopg3 ships a real async driver (psycopg.AsyncConnection), not just the threadpool-offload above. Opt into it explicitly:

configure(backend="postgres", conninfo="...", async_native=True)

Every a* method on Manager/QuerySet/Model — reads, writes, bulk operations, aget_or_create/aupdate_or_create, aaggregate, prefetch, aatomic() — then runs a genuine async call chain without using the worker threadpool for ORM operations at all — no asyncio.to_thread anywhere on this path (uvicorn, the logger, and other executors may still have their own worker threads elsewhere in the process; this is specifically about the ORM's own query execution). It's the same public API shown above; nothing in your handler code changes.

Opt-in, not the default — and PostgreSQL only

async_native=True never changes on its own; an existing deployment's behavior is untouched unless it explicitly asks for this. SQLite stays on the threadpool path, since the Python standard library doesn't ship a compatible async driver for it — async_native=True with backend="sqlite" raises ConfigurationError at configure() time, not at the first query.

When it's worth it

The threadpool path already keeps the event loop unblocked — this isn't about fixing something broken. It matters once your threadpool becomes the bottleneck under real concurrency: a bare Python process only has so many worker threads by default, and every a* call currently borrows one for the duration of its query (the thread is returned to the pool as soon as the query finishes). Native async has no such ceiling — it's genuinely one coroutine per in-flight query, same as the rest of an ASGI app.

One real, observable difference either way: task cancellation. Cancel a Task awaiting to_thread and the underlying thread keeps running the SQL query to completion regardless, even though the coroutine that was waiting on it has already been cancelled (the thread is orphaned, not stopped) — cancel a Task awaiting a native async query and the in-flight database call is actually cancelled too.

Sync M2M calls still need offloading

ManyRelatedManager (.add()/.remove()/.set()/.clear()) has no async API at all yet — sync or native. Calling it directly from a coroutine running on the event loop works but blocks the loop for that call; offload it the same way you would any other sync call from async code:

await asyncio.to_thread(book.tags.add, tag)

Closing native-async connections on shutdown

close_all() only closes the threadpool-backed pool. If any alias uses async_native=True, also await aclose_all() — typically from an on_shutdown hook:

# hooks.py
from endocore.orm import close_all, aclose_all

async def on_shutdown():
    close_all()
    await aclose_all()

Windows note

psycopg's async mode needs a selector-based event loop; Windows' default asyncio policy (ProactorEventLoop) can't run it and raises InterfaceError immediately on connect. This only affects local development on Windows — a production ASGI deployment (uvicorn on Linux/macOS) is unaffected. If you need to exercise async_native=True locally on Windows, run under a selector loop:

import asyncio

asyncio.run(main(), loop_factory=asyncio.SelectorEventLoop)  # Python 3.12+
# or, on 3.11:
loop = asyncio.SelectorEventLoop()
try:
    loop.run_until_complete(main())
finally:
    loop.close()