0

Optimiser les performances de sérialisation dans Django Rest Framework


Quand on choisit Python, Django ou Django Rest Framework (DRF), ce n’est généralement pas pour leur vitesse brute. Python reste avant tout le choix de l’ergonomie et du confort de développement — le langage que l’on privilégie pour livrer rapidement des fonctionnalités complexes plutôt que pour gagner quelques microsecondes.

Privilégier la productivité n’est pas un problème en soi. La grande majorité des projets n’a pas besoin d’une performance millimétrée, mais d’un code de qualité livré dans les temps.

Cependant, cela ne signifie pas que la performance est négligeable. Comme nous allons le voir, des gains majeurs peuvent être obtenus avec un peu d’analyse et quelques ajustements stratégiques.

Le problème : les performances du ModelSerializer

Il y a quelque temps, nous avons constaté des lenteurs inexpliquées sur l’un de nos principaux endpoints API. Puisque cet endpoint interrogeait une table volumineuse, notre premier réflexe a été d’incriminer la base de données.

Pourtant, même sur des jeux de données réduits, les performances restaient médiocres. En creusant davantage, nous avons identifié le véritable coupable : les sérialiseurs de Django Rest Framework.

Contexte : Pour ce benchmark, nous utilisons Python 3.7, Django 2.1.1 et Django Rest Framework 3.9.4.

L’approche “brute” : une fonction simple

Le rôle d’un sérialiseur est de transformer un objet complexe en un dictionnaire simple (et inversement). En essence, c’est une opération triviale. Testons une fonction basique qui transforme une instance User en dict :

from typing import Dict, Any
from django.contrib.auth.models import User

def serialize_user(user: User) -> Dict[str, Any]:
    return {
        'id': user.id,
        'last_login': user.last_login.isoformat() if user.last_login is not None else None,
        'is_superuser': user.is_superuser,
        'username': user.username,
        'first_name': user.first_name,
        'last_name': user.last_name,
        'email': user.email,
        'is_staff': user.is_staff,
        'is_active': user.is_active,
        'date_joined': user.date_joined.isoformat(),
    }

Créons un utilisateur test pour notre benchmark :

>>> from django.contrib.auth.models import User
>>> u = User.objects.create_user(
>>>     username='hakib',
>>>     first_name='haki',
>>>     last_name='benita',
>>>     email='me@hakibenita.com',
>>> )

Pour notre test, nous utilisons cProfile. Afin d’isoler la sérialisation des accès disque ou réseau, nous traitons un utilisateur déjà chargé 5 000 fois :

>>> import cProfile
>>> cProfile.run('for i in range(5000): serialize_user(u)', sort='tottime')
15003 function calls in 0.034 seconds

Cette fonction simple prend 0,034 seconde pour 5 000 opérations.

Le ModelSerializer de DRF

DRF propose le ModelSerializer pour automatiser cette tâche. Voici une implémentation classique pour notre modèle User :

from rest_framework import serializers

class UserModelSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = [
            'id',
            'last_login',
            'is_superuser',
            'username',
            'first_name',
            'last_name',
            'email',
            'is_staff',
            'is_active',
            'date_joined',
        ]

Lançons le même benchmark :

>>> cProfile.run('for i in range(5000): UserModelSerializer(u).data', sort='tottime')
18845053 function calls (18735053 primitive calls) in 12.818 seconds

Verdict : 12,8 secondes, soit environ 2,56 ms par utilisateur. C’est 377 fois plus lent que notre fonction manuelle.

L’analyse montre qu’une part énorme du temps est consommée par functional.py. ModelSerializer s’appuie sur la fonction lazy de Django pour gérer les validations et les métadonnées, ce qui alourdit considérablement le processus.

Le ModelSerializer en lecture seule (Read Only)

DRF n’ajoute les logiques de validation que pour les champs éditables. Pour tester l’impact de ce mécanisme, passons tous les champs en read_only :

from rest_framework import serializers

class UserReadOnlyModelSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = [
            'id',
            'last_login',
            'is_superuser',
            'username',
            'first_name',
            'last_name',
            'email',
            'is_staff',
            'is_active',
            'date_joined',
        ]
        read_only_fields = fields

Résultats :

>>> cProfile.run('for i in range(5000): UserReadOnlyModelSerializer(u).data', sort='tottime')
14540060 function calls (14450060 primitive calls) in 7.407 seconds

On tombe à 7,4 secondes, soit une amélioration de 40 %. C’est mieux, mais encore loin de la performance optimale.

Le sérialiseur classique (Serializer)

Pour mesurer le coût réel de l’automatisation du ModelSerializer, utilisons un Serializer standard en définissant manuellement les champs :

from rest_framework import serializers

class UserSerializer(serializers.Serializer):
    id = serializers.IntegerField()
    last_login = serializers.DateTimeField()
    is_superuser = serializers.BooleanField()
    username = serializers.CharField()
    first_name = serializers.CharField()
    last_name = serializers.CharField()
    email = serializers.EmailField()
    is_staff = serializers.BooleanField()
    is_active = serializers.BooleanField()
    date_joined = serializers.DateTimeField()

Benchmark :

>>> cProfile.run('for i in range(5000): UserSerializer(u).data', sort='tottime')
3110007 function calls (3010007 primitive calls) in 2.101 seconds

Le gain est massif : seulement 2,1 secondes. C’est 60 % plus rapide que le Read Only ModelSerializer et 85 % plus rapide que le ModelSerializer par défaut.

Le constat est clair : la magie du ModelSerializer a un prix.

Résumé des performances

Type de SérialiseurTemps (secondes)
UserModelSerializer12.818
UserReadOnlyModelSerializer7.407
UserSerializer2.101
UserReadOnlySerializer2.254
serialize_user (Fonction brute)0.034

Pourquoi une telle lenteur ?

La plupart des articles parlent d’optimiser l’ORM (select_related, prefetch_related), mais peu traitent de la sérialisation pure. Sans doute parce qu’on ne s’attend pas à ce que transformer un objet en JSON soit le goulot d’étranglement.

Tom Christie (créateur de DRF) soulignait déjà en 2013 :

“Vous n’avez pas toujours besoin d’utiliser des sérialiseurs.” Pour les vues ultra-critiques, n’hésitez pas à utiliser directment .values() dans vos QuerySets.

Améliorations de Django et DRF

Une grande partie de la lenteur provenait de la fonction lazy dans Django. Elle utilisait un cache pour les proxies de classes, mais un bug rendait ce cache inopérant, forçant une reconstruction coûteuse à chaque appel.

Ce bug a depuis été corrigé. Voici l’impact des correctifs (Avant vs Après) :

SérialiseurAvantAprèsÉvolution
UserModelSerializer12.8185.674-55%
UserReadOnlyModelSerializer7.4075.323-28%
UserSerializer2.1012.146+2%
UserReadOnlySerializer2.2542.125-5%

Graphique de performance

Résultats clés :

  • Le temps du ModelSerializer est divisé par deux.
  • Le ModelSerializer en lecture seule gagne 30 %.

Ce qu’il faut retenir

  1. Maintenez vos dépendances à jour : Les correctifs de Django et DRF apportent des gains de performance “gratuits”.
  2. Utilisez des Serializer classiques pour les endpoints critiques : Évitez le surcoût du ModelSerializer quand chaque milliseconde compte.
  3. Abusez du read_only=True : Si vous n’avez pas besoin de valider une donnée en entrée, marquez-la en lecture seule pour alléger le traitement.

Bonus : Automatiser les bonnes pratiques

Pour éviter que nos développeurs n’oublient ces optimisations, nous avons mis en place un check système Django :

# common/checks.py
import django.core.checks

@django.core.checks.register('rest_framework.serializers')
def check_serializers(app_configs, **kwargs):
    import inspect
    from rest_framework.serializers import ModelSerializer
    import conf.urls  # Force l'import de tous les sérialiseurs.

    for serializer in ModelSerializer.__subclasses__():
        # Ignorer les applications tierces
        path = inspect.getfile(serializer)
        if path.find('site-packages') > -1:
            continue

        if hasattr(serializer.Meta, 'read_only_fields'):
            continue

        yield django.core.checks.Warning(
            'ModelSerializer must define read_only_fields.',
            hint='Set read_only_fields in ModelSerializer.Meta',
            obj=serializer,
            id='H300',
        )

Désormais, un oubli génère un avertissement immédiat lors de l’exécution de manage.py check.


Cet article démontre qu’en matière de performance, ce sont souvent les petits détails d’implémentation qui font les plus grandes différences.

Commentaires