Skip to content

Async support

Intro

Since version 3.1, Django comes with async views support. This allows you run efficient concurrent views that are network and/or IO bound.

pip install Django>=3.1 django-ninja

Async views work more efficiently when it comes to:

  • calling external APIs over the network
  • executing/waiting for database queries
  • reading/writing from/to disk drives

Django Ninja takes full advantage of async views and makes it very easy to work with them.

Quick example

Code

Let's take an example. We have an API operation that does some work (currently just sleeps for provided number of seconds) and returns a word:

import time

@api.get("/say-after")
def say_after(request, delay: int, word: str):
    time.sleep(delay)
    return {"saying": word}

To make this code asynchronous, all you have to do is add the async keyword to a function (and use async aware libraries for work processing - in our case we will replace the stdlib sleep with asyncio.sleep):

import asyncio

@api.get("/say-after")
async def say_after(request, delay: int, word: str):
    await asyncio.sleep(delay)
    return {"saying": word}

Run

To run this code you need an ASGI server like Uvicorn or Daphne. Let's use Uvicorn for, example:

To install Uvicorn, use:

pip install uvicorn

Then start the server:

uvicorn your_project.asgi:application --reload

Note: replace your_project with your project package name
--reload flag used to automatically reload server if you do any changes to the code (do not use on production)

Note

You can run async views with manage.py runserver, but it does not work well with some libraries, so at this time (July 2020) it is recommended to use ASGI servers like Uvicorn or Daphne.

Test

Go to your browser and open http://127.0.0.1:8000/api/say-after?delay=3&word=hello (delay=3) After a 3-second wait you should see the "hello" message.

Now let's flood this operation with 100 parallel requests:

ab -c 100 -n 100 "http://127.0.0.1:8000/api/say-after?delay=3&word=hello"

which will result in something like this:

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    1   1.1      1       4
Processing:  3008 3063  16.2   3069    3082
Waiting:     3008 3062  15.7   3068    3079
Total:       3008 3065  16.3   3070    3083

Percentage of the requests served within a certain time (ms)
  50%   3070
  66%   3072
  75%   3075
  80%   3076
  90%   3081
  95%   3082
  98%   3083
  99%   3083
 100%   3083 (longest request)

Based on the numbers, our service was able to handle each of the 100 concurrent requests with just a little overhead.

To achieve the same concurrency with WSGI and sync operations you would need to spin up about 10 workers with 10 threads each!

Mixing sync and async operations

Keep in mind that you can use both sync and async operations in your project, and Django Ninja will route it automatically:

@api.get("/say-sync")
def say_after_sync(request, delay: int, word: str):
    time.sleep(delay)
    return {"saying": word}

@api.get("/say-async")
async def say_after_async(request, delay: int, word: str):
    await asyncio.sleep(delay)
    return {"saying": word}

Elasticsearch example

Let's take a real world use case. For this example, let's use the latest version of Elasticsearch that now comes with async support:

pip install elasticsearch>=7.8.0

And now instead of the Elasticsearch class, use the AsyncElasticsearch class and await the results:

from ninja import NinjaAPI
from elasticsearch import AsyncElasticsearch


api = NinjaAPI()

es = AsyncElasticsearch()


@api.get("/search")
async def search(request, q: str):
    resp = await es.search(
        index="documents", 
        body={"query": {"query_string": {"query": q}}},
        size=20,
    )
    return resp["hits"]

Using ORM

Currently, certain key parts of Django are not able to operate safely in an async environment, as they have global state that is not coroutine-aware. These parts of Django are classified as “async-unsafe”, and are protected from execution in an async environment. The ORM is the main example, but there are other parts that are also protected in this way.

Learn more about async safety here in the official Django docs.

So, if you do this:

@api.get("/blog/{post_id}")
async def search(request, post_id: int):
    blog = Blog.objects.get(pk=post_id)
    ...

it throws an error. Until the async ORM is implemented, you can use the sync_to_async() adapter:

from asgiref.sync import sync_to_async

@sync_to_async
def get_blog(post_id):
    return Blog.objects.get(pk=post_id)

@api.get("/blog/{post_id}")
async def search(request, post_id: int):
    blog = await get_blog(post_id)
    ...

or even shorter:

@api.get("/blog/{post_id}")
async def search(request, post_id: int):
    blog = await sync_to_async(Blog.objects.get)(pk=post_id)
    ...

There is a common GOTCHA: Django queryset's are lazily evaluated (database query happens only when you start iterating), so this will not work:

all_blogs = await sync_to_async(Blog.objects.all)()
# it will throw an error later when you try to iterate over all_blogs
...

Instead, use evaluation (with list):

all_blogs = await sync_to_async(list)(Blog.objects.all())
...

Since Django version 4.1, Django comes with asynchronous versions of ORM operations. These eliminate the need to use sync_to_async in most cases. The async operations have the same names as their sync counterparts but are prepended with a. So using the example above, you can rewrite it as:

@api.get("/blog/{post_id}")
async def search(request, post_id: int):
    blog = await Blog.objects.aget(pk=post_id)
    ...

When working with querysets, use async for paired with list comprehension:

all_blogs = [blog async for blog in Blog.objects.all()]
...

Learn more about the async ORM interface in the official Django docs.