We have traditionally used Django in all our products. We believe it is one of the most underrated, beautifully designed, rock solid framework out there.
However, if we are to be honest, the history of async usage in Django wasn't very impressive. It was always clunky, and you end up with your code cluttered with this ugly syntax.
async_to_sync(some_function)(arg_1, arg_2)
You could argue that for most products, you don’t really need async. It was just an extra layer of complexity without any significant practical benefit.
Over the last couple of years, AI use-cases have changed that perception. Many AI products have calling external APIs over the network as their bottleneck. This makes the complexity from async Python worth considering. FastAPI with its intuitive async usage and simplicity have risen to be the default API/web layer for AI projects.
We watched with concern as the view of Django as a clunky async framework spread. This happened partly because large language models had outdated information and partly because there aren't any complex or large open-source projects showcasing Django's async usage.
One of our side quests when building ColiVara was to demonstrate Django's async features and to be completely end-to-end async. We aimed to be an open-source project that could effectively showcase Django's async capabilities.
As background, ColiVara is a retrieval API that allows you to store, search, and retrieve documents based on their visual embeddings. It works exactly like RAG from the end-user standpoint - but using vision models instead of chunking and text-processing for documents.
It is designed as a Django API service with a separate, standalone GPU service that handles the AI workloads.
Footguns
The key takeaway is that your application needs full async support to truly benefit from it. It's a commitment you make from the start, and mixing sync and async code won't give you any benefits. It will only add unnecessary complexity.
If your code is a mix between sync and async, Django has to mimic the other call style to make your code work. This switch causes a slight performance delay of about a millisecond. Is it a big deal? Probably not if you have a sync call occasionally. However, if your application is a 50/50 mix, it's likely better to stick with sync code.
In summary, this means you must:
Use an ASGI web server to handle requests asynchronously
Write async views
Use an async HTTP client library like aiohttp for any API calls
Upgrade your custom middleware to be async-compatible
ASGI Web Server
For a Django project, the usual choices are either Daphne or Uvicorn. We think both have similar capabilities. We chose Uvicorn because it's lighter and more like "gunicorn." Our general rule is to use Daphne if we're working with WebSockets and Django Channels; otherwise, we go with Uvicorn.
Async views
We used django-ninja as our API library. It is newer than the well-known Django Rest Framework and focuses on performance and support for async code.
Our experience with django-ninja has been rock solid, and we recommend starting new API Django projects with it, even if you aren't writing async views or endpoints.
The coding style is very similar to FastAPI, but you also get all the Django features that make it "batteries-included."
Async ORM
The biggest improvement in Django over the last six months is how much the ORM now supports async code. Practically, the vast majority of operations are supported. You simply add a prefix of a
and things work out of the box.
User.objects.get(id=1) # sync
User.objects.aget(id=1) # async
One thing to be mindful of, is all the LLMs as of late 2024 are completely outdated on what is possible and what is not possible with Django async ORM operations. You should assume that their code is wrong, especially if you see it littered with async_to_sync
.
Async API calls
Here is where the big benefit for async paid off for us. The architecture of ColiVara is to call the GPU service whenever we need to do an AI workload. This is how this looks like:
# stuff here
async with aiohttp.ClientSession() as session:
async with session.post(
EMBEDDINGS_URL, json=payload, headers=headers
) as response:
if response.status != 200:
#handle error
response_data = await response.json()
# stuff here
In this code, when our application hits the await
keyword, it can yield control back to the event loop, allowing other tasks to run while waiting for the HTTP response.
Here's what happens:
When the code reaches the POST request, it doesn't block
While waiting for the response from
EMBEDDINGS_URL
, the event loop can:Handle other incoming requests
Process other async tasks
Run other coroutines
When the response comes back, our code resumes from where it left off
This is one of the main benefits of async programming - it allows for concurrent execution without using multiple threads or processes, particularly efficient for I/O-bound operations like HTTP requests.
Additionally, we can easily process items concurrently which leads to a big performance boost.
async def process_document(document):
# stuff here
return
documents = ['A', 'B', 'C', 'D']
# Process all items concurrently
results = await asyncio.gather(
*[process_document(document) for document in documents]
)
Async middleware
One of the main challenges with async Django is middleware. If you use synchronous middleware between an ASGI server and an async view, it switches to sync mode for the middleware and back to async for the view, causing overhead. Designing middleware to work both async and sync is simple; just be mindful of it. Here's a basic middleware that adds a slash to API requests as an example.
from asgiref.sync import iscoroutinefunction
from django.utils.decorators import sync_and_async_middleware
# add slash middleware
@sync_and_async_middleware
def add_slash(get_response):
if iscoroutinefunction(get_response):
async def middleware(request):
# we want to leave openapi, swagger and redoc as is
keep_as_is = any(
x in request.path for x in ["openapi", "swagger", "redoc", "docs"]
)
if not request.path.endswith("/") and not keep_as_is:
request.path_info = request.path = f"{request.path}/"
return await get_response(request)
else:
def middleware(request):
keep_as_is = any(
x in request.path for x in ["openapi", "swagger", "redoc", "docs"]
)
if not request.path.endswith("/") and not keep_as_is:
request.path_info = request.path = f"{request.path}/"
return get_response(request)
return middleware
Django will automatically pick the sync vs. the async path depending on the request.
We recommend thoroughly reviewing the middleware you use for async Django to ensure they support async usage. The switch overhead is generally minimal, but it could become problematic if left unchecked.
Conclusion
We believe async Django is ready for production. In theory, there should be no performance loss when using async Django instead of FastAPI for the same tasks. Django's built-in features greatly simplify and enhance the developer experience. Using async involves some complexity, as the entire code path must be async. In AI workloads, this complexity is often a worthwhile tradeoff.