Python JSON de/serialization speed-up and its impact on Django performance

Intro

This is a non-direct follow-up to Stress-testing Django, Django REST framework, FastAPI, Express.js, Go-chi and Axum.

The question we will answer is, how much can we squeeze from Django replacing the native json library. For most cases, standard json library does good enough. But sometimes, when we deal with huge payloads, it may be beneficial to do differently. In this article, we shall compare json, simplejson, orjson, python-rapidjson, ultrajson. Overall, there were some more, like hyperjson, perde, but such were already abandoned, or pyserde which I decided not to test because of a different usage pattern and the performance for this purpose wasn’t impressive. Once we find the winner, we will replace the JsonResponse from the test setup from previous article and re-run the brute force load test.

It is important to emphasize that we are only comparing the serialization and deserialization performance of these libraries, even though many of them offer additional features beyond this scope.

Testing the serialization and deserialization

We will use the same dictionary from the previous article. It was roughly 100kB. And we will serialize repeatedly and deserialize repeatedly.

The initial test is very simple:

import timeit

NUMBER = 100000

LIBS = ['json', 'orjson', 'ujson', 'simplejson', 'rapidjson']


def test_lib_dumps(lib: str):
    setup = f"""
import {lib}
from test_data import test_data
"""

    statement = f"""
{lib}.dumps(test_data)
"""

    result = timeit.timeit(
        statement,
        setup=setup,
        number=NUMBER
    )
    print(f'{lib}: {result}')


def test_lib_loads(lib: str):
    setup = f"""
import json
import {lib}
from test_data import test_data
pload = json.dumps(test_data)
"""

    statement = f"""
{lib}.loads(pload)
"""

    result = timeit.timeit(
        statement,
        setup=setup,
        number=NUMBER
    )
    print(f'{lib}: {result}')


if __name__ == '__main__':
    print('dumps')
    for x in LIBS:
        test_lib_dumps(x)

    print('loads')

    for x in LIBS:
        test_lib_loads(x)

Performing 100000 loads and dumps, the results are like this:

library dumps loads
json 20.7906353750004 17.3977316930013
orjson 1.65715706499941 6.32882171100027
ujson 12.8270320129996 19.7735966679993
simplejson 32.6206037989996 11.9569048459998
rapidjson 25.2785933599989 20.7954530970001

Serialization and deserialization results

We see some differences in the performence when doing serialization vs deserialization. And since there is a clear winner, we will continue with orjson.

Using orjson library in Django view replacing JsonResponse

Following the djangoapp from the previous article, we will create custom OrjsonResponse:

import orjson

from django.core.serializers.json import DjangoJSONEncoder
from django.http import HttpResponse


from testapp.test_data import test_data


# we don't really need to strictly follow the HttpResponse,
# but it is convenient for this example.
class OrjsonResponse(HttpResponse):
    def __init__(
        self,
        data,
        encoder=DjangoJSONEncoder, # leaving this for reference
        safe=True,
        json_dumps_params=None, # leaving this for reference
        **kwargs,
    ):
        if safe and not isinstance(data, dict):
            raise TypeError(
                "In order to allow non-dict objects to be serialized set the "
                "safe parameter to False."
            )
        if json_dumps_params is None:
            json_dumps_params = {}
        kwargs.setdefault("content_type", "application/json")
        # data = json.dumps(data, cls=encoder, **json_dumps_params) # leaving this for reference
        data = orjson.dumps(data)
        super().__init__(content=data, **kwargs)


def child_list_view(_request):
    return OrjsonResponse(test_data)

Leaving the OrjsonResponse with all init arguments and pretending that the json_dumps_params are fully compatible. What is not true. This is just a boilerplate we need to adjust for real world use.

Results

As in the previous article, we don’t respawn the forks, what could lead to a bit distorted results. It is however very individual how often to respawn what would affect the results a lot.

Elapsed Time

Concurrency 1 50 100 150 200 250
Django 0.62 5.55 11.13 16.58 22.39 33.65
Django+orjson 0.41 4.55 9.13 13.7 18.29 23.16
Go-chi 0.52 3.88 7.94 12.02 15.96 20.37

Elapsed Time plot

Response Time

Concurrency 1 50 100 150 200 250
Django 0 0.01 0.01 0.02 0.02 0.03
Django+orjson 0 0 0.01 0.01 0.02 0.02
Go-chi 0 0 0.01 0.01 0.02 0.02

Response Time plot

Transaction rate/s

Concurrency 1 50 100 150 200 250
Django 1612.9 9009.01 8984.73 9047.04 8932.56 7429.42
Django+orjson 2439.02 10989.01 10952.9 10948.91 10934.94 10794.47
Go-chi 1923.08 12886.6 12594.46 12479.2 12531.33 12272.95

Transaction rate/s plot

Throughput MB/s

Concurrency 1 50 100 150 200 250
Django 153.87 859.47 857.15 863.1 852.17 708.77
Django+orjson 232.68 1048.36 1044.92 1044.53 1043.2 1029.8
Go-chi 183.45 1229.32 1201.45 1190.45 1195.43 1170.78

Throughput MB/s plot

Longest transaction

Concurrency 1 50 100 150 200 250
Django 0.01 0.05 0.05 0.06 0.06 0.12
Django+orjson 0.01 0.05 0.05 0.08 0.07 0.08
Go-chi 0.01 0.04 0.11 0.21 0.29 0.41

Longest Transaction plot

Outro

Do we need to change the standard library? If not sure, probably not. Most times, the bottleneck will be somewhere else, but if we serialize/deserialize plenty of data, considering a drop-in replacement is worth it. We need to take in account that this change solves only a single case/problem and doesn’t mean it will bring us magic gains in all circumstances.

Tags