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 QuerySet —
filter(),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 aManyToManyFieldhave 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/adeletelive 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/asyncioexecutor) 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:
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:
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: