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 |
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 |
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 |
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 |
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 |
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 |
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.