Serializers DRF avancés : nested, writable et performances

Les serializers sont le cœur de Django REST Framework. Au-delà de la conversion basique model/JSON, ils permettent de gérer des structures imbriquées, des validations complexes et des transformations de données. Voici les patterns avancés pour les maîtriser.

Nested Serializers : lecture vs écriture

Imbriquer des serializers pour afficher des relations est simple. Les rendre writable demande du travail supplémentaire.

Lecture seule (le cas simple)

                
api/serializers.py
from rest_framework import serializers from .models import Order, OrderLine, Product class ProductSerializer(serializers.ModelSerializer): class Meta: model = Product fields = ['id', 'name', 'price'] class OrderLineSerializer(serializers.ModelSerializer): # Nested en lecture seule product = ProductSerializer(read_only=True) class Meta: model = OrderLine fields = ['id', 'product', 'quantity', 'unit_price'] class OrderSerializer(serializers.ModelSerializer): # many=True pour les relations OneToMany lines = OrderLineSerializer(many=True, read_only=True) class Meta: model = Order fields = ['id', 'customer', 'status', 'lines', 'total']

Résultat JSON :

                
Réponse API
{ "id": 42, "customer": "client@example.com", "status": "pending", "lines": [ { "id": 1, "product": { "id": 10, "name": "Widget Pro", "price": "29.99" }, "quantity": 2, "unit_price": "29.99" } ], "total": "59.98" }

Writable Nested Serializers

Pour créer/modifier des objets imbriqués, DRF exige une implémentation explicite de create() et update().

Pourquoi DRF ne le fait pas automatiquement ? Les relations imbriquées impliquent des décisions métier : créer un nouvel objet ou lier un existant ? Que faire si la validation échoue à mi-parcours ? DRF préfère vous laisser décider.

Pattern : création avec nested

                
api/serializers.py
from django.db import transaction from rest_framework import serializers class OrderLineWriteSerializer(serializers.ModelSerializer): # Accepte l'ID du produit en entrée product_id = serializers.IntegerField() class Meta: model = OrderLine fields = ['product_id', 'quantity'] class OrderWriteSerializer(serializers.ModelSerializer): lines = OrderLineWriteSerializer(many=True) class Meta: model = Order fields = ['customer_email', 'lines'] @transaction.atomic def create(self, validated_data): # Extraire les données imbriquées AVANT de créer le parent lines_data = validated_data.pop('lines') # Créer la commande order = Order.objects.create(**validated_data) # Créer les lignes avec le bon prix for line_data in lines_data: product = Product.objects.get(id=line_data['product_id']) OrderLine.objects.create( order=order, product=product, quantity=line_data['quantity'], unit_price=product.price # Prix figé au moment de la commande ) return order

@transaction.atomic

Toujours wrapper la création nested dans une transaction. Si la création d'une ligne échoue, tout est annulé : pas de commande orpheline sans lignes.

Pattern : update avec nested (plus complexe)

                
api/serializers.py
class OrderWriteSerializer(serializers.ModelSerializer): lines = OrderLineWriteSerializer(many=True) class Meta: model = Order fields = ['customer_email', 'lines'] @transaction.atomic def update(self, instance, validated_data): lines_data = validated_data.pop('lines', None) # Mettre à jour les champs simples for attr, value in validated_data.items(): setattr(instance, attr, value) instance.save() # Gérer les lignes si fournies if lines_data is not None: # Stratégie : remplacer toutes les lignes instance.lines.all().delete() for line_data in lines_data: product = Product.objects.get(id=line_data['product_id']) OrderLine.objects.create( order=instance, product=product, quantity=line_data['quantity'], unit_price=product.price ) return instance

Stratégies d'update nested :

  • Replace all : supprimer et recréer (simple mais destructeur)
  • Patch by ID : identifier chaque élément par son ID, créer/modifier/supprimer
  • Append only : n'autoriser que l'ajout de nouveaux éléments

Serializers différents pour lecture/écriture

Pattern courant : utiliser des serializers distincts selon l'opération. La lecture retourne plus de données (nested, computed), l'écriture accepte des IDs.

                
api/views.py
from rest_framework import viewsets from .serializers import OrderReadSerializer, OrderWriteSerializer class OrderViewSet(viewsets.ModelViewSet): queryset = Order.objects.all() def get_serializer_class(self): if self.action in ['create', 'update', 'partial_update']: return OrderWriteSerializer return OrderReadSerializer

Alternative : to_representation override

Pour un seul serializer qui retourne des données enrichies après écriture :

                
api/serializers.py
class OrderSerializer(serializers.ModelSerializer): # Écriture : accepte product_id lines = OrderLineWriteSerializer(many=True, write_only=True) class Meta: model = Order fields = ['id', 'customer_email', 'lines', 'status'] def to_representation(self, instance): # Lecture : retourne le serializer complet return OrderReadSerializer(instance, context=self.context).data def create(self, validated_data): # ... logique de création pass

Validation avancée

DRF offre plusieurs niveaux de validation, du champ individuel à l'objet entier.

Validation au niveau du champ

                
api/serializers.py
from rest_framework import serializers class ProductSerializer(serializers.ModelSerializer): class Meta: model = Product fields = ['name', 'price', 'sku'] def validate_price(self, value): """Validation spécifique au champ price.""" if value <= 0: raise serializers.ValidationError("Le prix doit être positif") if value > 99999: raise serializers.ValidationError("Prix maximum dépassé") return value def validate_sku(self, value): """SKU doit être alphanumérique.""" if not value.isalnum(): raise serializers.ValidationError("SKU invalide") return value.upper() # Normalisation

Validation cross-field

                
api/serializers.py
class PromotionSerializer(serializers.ModelSerializer): class Meta: model = Promotion fields = ['name', 'start_date', 'end_date', 'discount_percent'] def validate(self, attrs): """Validation de l'objet entier.""" start = attrs.get('start_date') end = attrs.get('end_date') if start and end and start >= end: raise serializers.ValidationError({ 'end_date': "La date de fin doit être après le début" }) # Vérifier les chevauchements overlapping = Promotion.objects.filter( start_date__lt=end, end_date__gt=start ) # Exclure l'instance courante en update if self.instance: overlapping = overlapping.exclude(pk=self.instance.pk) if overlapping.exists(): raise serializers.ValidationError( "Cette promotion chevauche une promotion existante" ) return attrs

Validators réutilisables

                
api/validators.py
from rest_framework import serializers class FutureDateValidator: """Valide qu'une date est dans le futur.""" def __init__(self, message=None): self.message = message or "La date doit être dans le futur" def __call__(self, value): from django.utils import timezone if value <= timezone.now(): raise serializers.ValidationError(self.message) class UniqueForUserValidator: """Valide l'unicité d'un champ pour l'utilisateur courant.""" requires_context = True def __init__(self, model, field): self.model = model self.field = field def __call__(self, value, serializer_field): user = serializer_field.context['request'].user qs = self.model.objects.filter( user=user, **{self.field: value} ) # Exclure l'instance courante en update instance = serializer_field.parent.instance if instance: qs = qs.exclude(pk=instance.pk) if qs.exists(): raise serializers.ValidationError( f"Vous avez déjà un élément avec ce {self.field}" )
                
Utilisation
class EventSerializer(serializers.ModelSerializer): start_date = serializers.DateTimeField( validators=[FutureDateValidator()] ) name = serializers.CharField( validators=[UniqueForUserValidator(Event, 'name')] ) class Meta: model = Event fields = ['name', 'start_date', 'description']

Champs dynamiques et contextuels

Les serializers peuvent adapter leurs champs selon le contexte (utilisateur, action, permissions).

Champs conditionnels

                
api/serializers.py
class UserSerializer(serializers.ModelSerializer): class Meta: model = User fields = ['id', 'username', 'email', 'is_staff', 'last_login'] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) request = self.context.get('request') if not request: return # Seuls les admins voient is_staff et last_login if not request.user.is_staff: self.fields.pop('is_staff', None) self.fields.pop('last_login', None) # L'email n'est visible que par le propriétaire if self.instance and self.instance != request.user: self.fields.pop('email', None)

Pattern : DynamicFieldsSerializer

                
api/mixins.py
class DynamicFieldsMixin: """ Permet de spécifier les champs via query params. GET /users/?fields=id,username,email """ def __init__(self, *args, **kwargs): # Champs explicitement demandés fields = kwargs.pop('fields', None) super().__init__(*args, **kwargs) if fields is None: request = self.context.get('request') if request: fields = request.query_params.get('fields') if fields: fields = fields.split(',') allowed = set(fields) existing = set(self.fields) # Supprimer les champs non demandés for field_name in existing - allowed: self.fields.pop(field_name) class UserSerializer(DynamicFieldsMixin, serializers.ModelSerializer): class Meta: model = User fields = ['id', 'username', 'email', 'first_name', 'last_name']

Optimisation des performances

Les nested serializers sont gourmands en requêtes. Sans précaution, vous tombez vite dans le piège N+1.

Le problème

                
Mauvais : N+1 queries
# Pour chaque commande, DRF va charger les lignes # Pour chaque ligne, DRF va charger le produit # 1 + N + N*M requêtes ! class OrderViewSet(viewsets.ModelViewSet): queryset = Order.objects.all() # Pas de prefetch serializer_class = OrderSerializer

La solution : select_related et prefetch_related

                
api/views.py
class OrderViewSet(viewsets.ModelViewSet): serializer_class = OrderSerializer def get_queryset(self): return Order.objects.select_related( 'customer' # ForeignKey ).prefetch_related( 'lines', # OneToMany 'lines__product', # Nested ForeignKey 'lines__product__category' # Encore plus profond )

select_related vs prefetch_related

select_related : JOIN SQL, pour ForeignKey et OneToOne.
prefetch_related : requête séparée, pour ManyToMany et reverse FK.

Pattern : setup_eager_loading

Centralisez la logique de prefetch dans le serializer :

                
api/serializers.py
class OrderSerializer(serializers.ModelSerializer): lines = OrderLineSerializer(many=True) class Meta: model = Order fields = ['id', 'customer', 'lines', 'total'] @staticmethod def setup_eager_loading(queryset): """Optimise le queryset pour ce serializer.""" return queryset.select_related( 'customer' ).prefetch_related( 'lines__product' )
                
api/views.py
class OrderViewSet(viewsets.ModelViewSet): serializer_class = OrderSerializer def get_queryset(self): qs = Order.objects.filter(user=self.request.user) return OrderSerializer.setup_eager_loading(qs)

SerializerMethodField pour les données calculées

Pour des données qui ne viennent pas directement du modèle :

                
api/serializers.py
class ProductSerializer(serializers.ModelSerializer): # Champ calculé is_available = serializers.SerializerMethodField() discount_price = serializers.SerializerMethodField() stock_status = serializers.SerializerMethodField() class Meta: model = Product fields = [ 'id', 'name', 'price', 'is_available', 'discount_price', 'stock_status' ] def get_is_available(self, obj): return obj.stock > 0 and obj.status == 'active' def get_discount_price(self, obj): # Utilise le contexte pour la promotion active promotion = self.context.get('active_promotion') if promotion and obj in promotion.products.all(): return obj.price * (1 - promotion.discount / 100) return None def get_stock_status(self, obj): if obj.stock == 0: return 'out_of_stock' elif obj.stock < 5: return 'low_stock' return 'in_stock'

Attention aux N+1 dans SerializerMethodField ! Si votre méthode fait une requête DB, elle sera exécutée pour chaque objet. Préférez les annotations ou le prefetch.

Alternative : annotation dans le queryset

                
Plus performant
from django.db.models import Count, Avg, Case, When, Value class ProductViewSet(viewsets.ModelViewSet): def get_queryset(self): return Product.objects.annotate( review_count=Count('reviews'), avg_rating=Avg('reviews__rating'), stock_status=Case( When(stock=0, then=Value('out_of_stock')), When(stock__lt=5, then=Value('low_stock')), default=Value('in_stock') ) ) class ProductSerializer(serializers.ModelSerializer): # Champs annotés (pas de requête supplémentaire) review_count = serializers.IntegerField(read_only=True) avg_rating = serializers.FloatField(read_only=True) stock_status = serializers.CharField(read_only=True) class Meta: model = Product fields = ['id', 'name', 'review_count', 'avg_rating', 'stock_status']

Bonnes pratiques résumées

  • Nested writable : toujours explicite via create()/update() avec @transaction.atomic
  • Lecture vs écriture : serializers séparés ou to_representation override
  • Validation : validate_field() pour un champ, validate() pour cross-field
  • Performance : select_related/prefetch_related obligatoires avec nested
  • Données calculées : préférer les annotations aux SerializerMethodField
  • Contexte : utiliser self.context pour accéder à la request et aux données externes

Besoin d'aide sur votre projet ?

Je peux vous accompagner dans le développement ou l'optimisation de votre application.

Me contacter