This release is a pre-release and may not be stable for production use.
django-goroutine
English · Français
Release candidate.
django-goroutineis at1.0.0rc2: the API is considered frozen but has not yet been battle-tested by real-world usage outside this repository. Feedback (issues, use cases, bugs) is welcome before the final1.0.0is tagged — see RELEASING.md.
A structured concurrent-task orchestrator for Django, inspired by Go's concurrency model — without pretending to reproduce it identically.
The problem
Django can run views and ORM methods with async/await, but it gives you
no off-the-shelf tool for launching several independent operations in
parallel inside a view. Rolling your own with asyncio.gather quickly runs
into three obstacles: the ORM's connection pool (historically sync), which
misbehaves across multiple threads; request context (user, language,
session), which doesn't always propagate cleanly; and a dozen lines of
plumbing to cancel/clean up if a subtask fails. django-goroutine provides
group() (built on top of asyncio.TaskGroup) and cpu_map() (built on
top of a persistent ProcessPoolExecutor) to cover these three obstacles,
with per-task errors returned as
pycatch.Result rather than
raised.
What "goroutine" doesn't mean here
A Go goroutine is a green thread managed by the runtime, able to migrate
between OS threads. Python keeps a single, cooperative event loop:
group() doesn't replicate that model, it borrows its spirit — the call
site decides what to run concurrently, not the function itself — with
three distinct ways to run a task depending on its actual nature:
| Decorator | Nature of the task | Runs on |
|---|---|---|
@io (or an undecorated coroutine) |
Network/disk wait (async def) |
The event loop, no dedicated thread or process |
@db |
Blocking sync ORM call | The dedicated thread pool (GOROUTINE["DB_POOL_SIZE"]) |
@cpu |
Sync CPU-bound computation | The dedicated process pool (GOROUTINE["CPU_POOL_SIZE"]) |
This explicit choice, rather than automatic detection, is deliberate: a heuristic (timing, introspection) for guessing a function's nature would be unreliable and would reproduce exactly the kind of sneaky bugs this project is trying to avoid.
Installation
uv add django-goroutine
pip install django-goroutine
Since a release candidate isn't a final version, PyPI doesn't install it by
default with pip install django-goroutine — use --pre or pin the exact
version until 1.0.0 is tagged:
uv add "django-goroutine==1.0.0rc2"
pip install "django-goroutine==1.0.0rc2"
# settings.py
INSTALLED_APPS = [
...,
"django_goroutine",
]
ready() starts the thread pool (@db) at boot rather than on first call,
so its creation cost isn't paid on the first request that uses it. The
process pool (@cpu) stays deliberately lazy (created on the first actual
call) — see the Known limitations section.
Quick start
from django_goroutine import db, group, io
@db
def fetch_user(user_id: int) -> User:
return User.objects.get(pk=user_id)
@io
async def fetch_avatar(url: str) -> bytes:
async with httpx.AsyncClient() as client:
response = await client.get(url)
return response.content
async def profile_view(request, user_id):
async with group() as g:
user_task = g.go(fetch_user, user_id)
avatar_task = g.go(fetch_avatar, avatar_url)
match user_task.result():
case Ok(user):
...
case Err(err):
...
fetch_user and fetch_avatar run in parallel: the view's response time
drops to the time of the slower of the two, not their sum.
To see all of this running without configuring anything yourself, see
examples/: a minimal Django project with views that
demonstrate group(), cpu_map(), timeout, and backpressure, launchable
with a single command from this repository.
Inheritance of internal calls
A function called from fetch_user (an internal helper, a second ORM
call) simply runs in the same call stack, on the same thread — no further
decoration is needed or useful. The decorator only matters at the moment
Group.go() dispatches the task, not for internal call propagation.
Per-task errors, no cascading cancellation on business errors
Any exception raised by a dispatched task becomes an Err carried by its
TaskHandle: it never crashes sibling tasks or the enclosing block —
group() is a sync.WaitGroup, not an errgroup with automatic
cancellation on business error. Cancellation is still possible, but only
structurally: if the async with group() block itself is cancelled from
the outside (ASGI server timeout, client disconnect), the tasks still in
flight are cancelled by asyncio.TaskGroup, like any other await.
async with group() as g:
a = g.go(fetch_user, user_id) # fails
b = g.go(fetch_avatar, avatar_url) # keeps running regardless, not cancelled
a.result() # Err(UserDoesNotExist(...))
b.result() # Ok(b"...")
TaskHandle.result() can only be read once the async with group() block
has closed — calling it before that raises RuntimeError.
Per-task timeout
A default timeout can't be set on Group.go() itself (its *args/
**kwargs are already reserved for forwarding to the decorated function),
so @io/@db/@cpu are used bare or parameterized:
@db(timeout=2.0)
def fetch_user(user_id: int) -> User:
return User.objects.get(pk=user_id)
Past timeout seconds, TaskHandle.result() becomes
Err(TimeoutError(...)) — without crashing sibling tasks, like any other
failure. GOROUTINE["TASK_TIMEOUT"] sets a default value for tasks that
don't specify their own (None by default, so no timeout at all until
something is configured). An important caveat to know: Python cannot
forcibly interrupt a thread or process already running the task — past the
timeout, the caller stops waiting and gets its Err back, but the
thread/process keeps executing the task in the background until it
naturally finishes.
Backpressure
The number of @db/@cpu tasks simultaneously queued or in flight is
bounded (GOROUTINE["DB_MAX_PENDING"]/CPU_MAX_PENDING, defaulting to 4×
the size of the corresponding pool): beyond that, a new call waits for a
slot to free up instead of piling up without limit in the executor's
internal queue — this is what keeps a load spike from blowing up memory or
latency instead of failing or waiting cleanly. This combines naturally with
timeout, which then bounds both the wait for a free slot and the
execution itself.
@cpu pool auto-recovery
If a @cpu pool worker crashes hard (segfault, os._exit...), the whole
pool becomes unusable (BrokenProcessPool) until it's recreated.
django-goroutine detects this case and resets the pool automatically: the
call in flight fails (Err(BrokenProcessPool(...)), no automatic retry —
replaying a function that may already have had side effects would be
worse), but subsequent calls get a healthy pool back instead of staying
broken indefinitely.
Because the pool is shared, a crash isn't necessarily isolated to the task
that caused it: ProcessPoolExecutor marks every task still queued or in
flight on that same pool as Err(BrokenProcessPool(...)) too, not only the
one whose worker actually died — a perfectly healthy sibling @cpu task
running at the same moment can become collateral damage of a crash that has
nothing to do with it. This is a ProcessPoolExecutor characteristic, not
something django-goroutine can isolate away without giving up a pool
shared across calls; @io/@db tasks in the same group() are
unaffected.
Parallelizing a CPU-bound computation (cpu_map)
@cpu on group().go() only saves time on work already split into
independent units. A single CPU-bound function dispatched alone doesn't
speed anything up — exactly like a single Go goroutine doesn't speed up a
monolithic computation: the gain always comes from splitting into
independent units spread across several cores, never from the
orchestration tool itself. cpu_map() covers the case where that split
already exists:
from django_goroutine import cpu_map
def resize_one(image_bytes: bytes) -> bytes:
...
async def batch_resize_view(request, images):
results = await cpu_map(resize_one, images)
...
fn must be a sync CPU-bound function, importable at module level (a
pickle constraint of the underlying ProcessPoolExecutor) — never a
lambda, a closure, or a bound instance method. A failure on one element,
including a timeout overrun (seconds, optional, applied individually to
each element — cpu_map(fn, items, timeout=5.0)) or a broken @cpu pool
(auto-reset), doesn't fail the others: each result is an independent
Result, in input order. cpu_map() shares the same backpressure
semaphore as group()'s @cpu tasks — both compete for the same
resource.
The GIL prevents two threads from executing Python bytecode in parallel: if
your heavy computation already goes through a C library that releases the
GIL (numpy, Pillow, OpenCV, hashlib...), @db-style thread offload would
be enough — @cpu/cpu_map() only bring a real gain for pure CPU-bound
Python code, via separate processes.
Configuration
GOROUTINE = {
"DB_POOL_SIZE": 10, # @db thread pool size
"CPU_POOL_SIZE": os.cpu_count(), # @cpu process pool size
"DB_MAX_PENDING": None, # @db backpressure; None => DB_POOL_SIZE * 4
"CPU_MAX_PENDING": None, # @cpu/cpu_map backpressure; None => CPU_POOL_SIZE * 4
"TASK_TIMEOUT": None, # default timeout (seconds); None => none
}
Logging
All task and pool events go through the Python logger
"django_goroutine", to be wired up like any other Django logger:
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {"console": {"class": "logging.StreamHandler"}},
"loggers": {
"django_goroutine": {"handlers": ["console"], "level": "INFO"},
},
}
| Level | Emitted for |
|---|---|
DEBUG |
An @io/@db/@cpu/cpu_map() task fails with a business exception (normal case, handled via Result — no noise by default). |
INFO |
A pool starting up (@db at boot, @cpu on first call). |
WARNING |
A task exceeds its timeout. |
ERROR |
The @cpu pool is broken (BrokenProcessPool) and resets. |
Without an explicit LOGGING configuration, Django already logs WARNING
and above to the console via its default root handler — only DEBUG
requires explicit configuration to become visible.
Known limitations
- sqlite and concurrent writes. sqlite only has a global write lock:
several
@dbfunctions writing in parallel against a sqlite database can raiseOperationalError("database is locked"). PostgreSQL and MySQL absorb concurrent writes without this lock — in development with sqlite, increaseOPTIONS.timeoutor avoid concurrent writes on the same pool. - The
@cpupool is lazy, not started byapps.ready(). Two reasons, not a matter of taste:ready()runs for any process that loads the Django app —migrate,shell, or evenmypyvia the django-stubs plugin, which really does calldjango.setup()— not just an application server; spawning OS processes every time would have made it a source of resource leaks on commands that never use@cpu. And starting aProcessPoolExecutorbefore a fork (gunicorn--preload) is a known source ofmultiprocessingdeadlocks — lazy creation eliminates this risk along the way, the pool being created in each worker after the fork, not before. The pool also uses thespawnmultiprocessing context rather than Linux's defaultfork: forking a multi-threaded process (asyncio loop +@dbpool) can freeze the child if a thread held an internal lock at fork time —spawnstarts a fresh interpreter, slower on the first call but without that inheritance. Eachspawnworker callsdjango.setup()on startup (via the pool'sinitializer) so it stays importable even if its module touches, even indirectly, Django models. - No automatic cancellation of sibling tasks on business error.
group()is deliberately async.WaitGroup, not anerrgroupwith cancellation on first failure — see the dedicated section above. Acancel_on_errormode could be added in a future minor version if the need is confirmed by usage. @cpu/cpu_map()require module-level picklable functions. A constraint ofProcessPoolExecutor, not of this project — a lambda, a closure, or a bound method silently fail to be pickled.- A
@cpupool crash can affect healthy sibling tasks. See the@cpupool auto-recovery section above:ProcessPoolExecutorfails every task still queued or in flight on the pool when one worker crashes hard, not just the task that triggered the crash.
Development
uv sync --group dev
uv run ruff check src tests
uv run ruff format --check src tests
uv run mypy
uv run pytest --cov=django_goroutine --cov-report=term-missing
See CONTRIBUTING.md to contribute, CHANGELOG.md for the version history, and RELEASING.md for the release process.
License
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_goroutine-1.0.0rc2.tar.gz.
File metadata
- Download URL: django_goroutine-1.0.0rc2.tar.gz
- Upload date:
- Size: 90.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b89430241f931e67da50150a2d612730fe46c0f934558a83a7791136160215a3
|
|
| MD5 |
100a851953f8de32e04e1f5495764eba
|
|
| BLAKE2b-256 |
c31eff10c287949699c0ca82bcf39199dfa47d9501335ed765ff14813d13b8af
|
Provenance
The following attestation bundles were made for django_goroutine-1.0.0rc2.tar.gz:
Publisher:
publish.yml on alzeph/django-goroutine
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_goroutine-1.0.0rc2.tar.gz -
Subject digest:
b89430241f931e67da50150a2d612730fe46c0f934558a83a7791136160215a3 - Sigstore transparency entry: 2529461912
- Sigstore integration time:
-
Permalink:
alzeph/django-goroutine@2f36342d3054e2a37efa5fe72c6e2aca54198a1a -
Branch / Tag:
refs/tags/v1.0.0rc2 - Owner: https://github.com/alzeph
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@2f36342d3054e2a37efa5fe72c6e2aca54198a1a -
Trigger Event:
release
-
Statement type:
File details
Details for the file django_goroutine-1.0.0rc2-py3-none-any.whl.
File metadata
- Download URL: django_goroutine-1.0.0rc2-py3-none-any.whl
- Upload date:
- Size: 21.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
522519f64f4631e717569609880c42fdff49683d6038344dd2448c26129af238
|
|
| MD5 |
15243115e89abd183a02cdfc190b5f7a
|
|
| BLAKE2b-256 |
16d0c9c8d2ff020193bcabe8150ddfc596a0aa24b03d4c7c6e6943909cae332e
|
Provenance
The following attestation bundles were made for django_goroutine-1.0.0rc2-py3-none-any.whl:
Publisher:
publish.yml on alzeph/django-goroutine
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_goroutine-1.0.0rc2-py3-none-any.whl -
Subject digest:
522519f64f4631e717569609880c42fdff49683d6038344dd2448c26129af238 - Sigstore transparency entry: 2529462463
- Sigstore integration time:
-
Permalink:
alzeph/django-goroutine@2f36342d3054e2a37efa5fe72c6e2aca54198a1a -
Branch / Tag:
refs/tags/v1.0.0rc2 - Owner: https://github.com/alzeph
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@2f36342d3054e2a37efa5fe72c6e2aca54198a1a -
Trigger Event:
release
-
Statement type: