# Django et PostgreSQL en 2026 : indexation, recherche plein texte et questions d'entretien > Guide pratique de l'optimisation Django PostgreSQL : index B-tree, partiels et couvrants, recherche plein texte avec SearchVector et GIN, plus les questions d'entretien 2026. - Published: 2026-06-27 - Updated: 2026-07-06 - Author: SharpSkill - Tags: django, postgresql, database optimization, full text search, python - Reading time: 9 min --- L'optimisation Django PostgreSQL fait la différence entre une application qui passe le cap des premiers milliers d'utilisateurs et une autre qui s'effondre à chaque vue de liste. PostgreSQL embarque un moteur d'indexation et un sous-système de recherche plein texte que la plupart des projets Django laissent inexploités, gaspillant des performances de requête qu'il ne coûte rien de récupérer. Ce guide couvre les patterns d'indexation, les outils de recherche de `django.contrib.postgres` et les questions d'entretien sur les bases de données qui distinguent les développeurs Django intermédiaires des profils seniors en 2026. > **Mesurer avant d'indexer** > > Chaque index accélère les lectures, mais ralentit les écritures et consomme de l'espace disque. Il faut lancer `EXPLAIN (ANALYZE, BUFFERS)` sur la requête réelle, ajouter l'index, puis comparer le plan. Un index que le planificateur ne choisit jamais représente une surcharge pure à chaque INSERT et UPDATE. ## Les fondamentaux de l'indexation PostgreSQL dans Django Par défaut, PostgreSQL crée un index B-tree uniquement sur les clés primaires et les colonnes définies avec `unique=True`. Toute autre colonne filtrée ou triée déclenche un parcours séquentiel tant qu'aucun index n'existe. Django expose les index via `Meta.indexes`, une approche préférable à l'ancien `db_index=True` car elle regroupe au même endroit les index composites, partiels et couvrants. L'ordre des colonnes dans un index composite n'a rien de cosmétique. Un B-tree ne peut exploiter un index pour une requête que si le filtre utilise un préfixe de tête des colonnes indexées, une règle détaillée sur [Use The Index, Luke](https://use-the-index-luke.com/sql/where-clause/the-equals-operator/concatenated-keys). Un index sur `(status, created_at)` répond à `WHERE status = ? ORDER BY created_at`, mais ce même index ne peut accélérer une requête qui filtre uniquement sur `created_at`. ```python # models.py from django.db import models class Order(models.Model): customer_email = models.EmailField() status = models.CharField(max_length=20) total = models.DecimalField(max_digits=10, decimal_places=2) created_at = models.DateTimeField(auto_now_add=True) class Meta: indexes = [ # Index B-tree mono-colonne pour recherches exactes et par plage sur status models.Index(fields=["status"]), # Index composite : répond à WHERE status = ? ORDER BY created_at DESC # La colonne de tête (status) doit figurer dans le filtre pour être utilisée models.Index(fields=["status", "-created_at"]), ] ``` L'index composite transforme une requête de tableau de bord comme « dernières commandes en attente » en un simple parcours d'index, au lieu d'un filtrage suivi d'un tri sur toute la table. ## Index partiels et couvrants pour des requêtes ciblées Un index partiel ne couvre que les lignes correspondant à une condition, ce qui le maintient compact et en mémoire. Lorsque la plupart des lignes partagent une même valeur et que les requêtes ne visent que la minorité, un index partiel revient nettement moins cher qu'un index complet. Un index couvrant va plus loin : en ajoutant des colonnes non clés avec `include`, PostgreSQL répond à la requête directement depuis l'index, sans toucher au tas de la table, un mode d'accès appelé index-only scan. ```python # models.py from django.db import models from django.db.models import Q class Order(models.Model): # champs définis ci-dessus class Meta: indexes = [ # Index partiel : n'indexe que les lignes en attente, ignore l'historique terminé models.Index( fields=["created_at"], name="pending_orders_idx", condition=Q(status="pending"), ), # Index couvrant : total est lu directement depuis l'index (index-only scan) models.Index( fields=["status"], include=["total"], name="status_total_covering_idx", ), ] ``` Sur une table de commandes où 2 % des lignes sont en attente, l'index partiel peut être cinquante fois plus petit qu'un index complet sur la même colonne, et le planificateur le garde chaud dans le cache tampon. La clause `include` est documentée dans [PostgreSQL index-only scans](https://www.postgresql.org/docs/current/indexes-index-only-scans.html) et constitue le gain le plus souvent négligé dans les applications Django qui agrègent une colonne numérique derrière un filtre de statut. ## Recherche plein texte Django avec PostgreSQL Recourir à `title__icontains=query` paraît pratique, mais force un parcours séquentiel et ne peut classer les résultats par pertinence. La recherche plein texte de Django encapsule les types natifs `tsvector` et `tsquery` de PostgreSQL via `SearchVector`, `SearchQuery` et `SearchRank`. Un vecteur de recherche normalise le texte en lexèmes, supprime les mots vides et ramène les mots à leur racine, si bien qu'une recherche sur « running » correspond aussi à « run » et « ran ». ```python # views.py from django.contrib.postgres.search import ( SearchVector, SearchQuery, SearchRank, ) from .models import Article def search_articles(query_text): query = SearchQuery(query_text, config="english") # Le poids A classe les correspondances de title avant celles du body (poids B) vector = ( SearchVector("title", weight="A") + SearchVector("body", weight="B") ) return ( Article.objects.annotate(rank=SearchRank(vector, query)) .filter(rank__gte=0.1) .order_by("-rank") ) ``` Le paramètre `weight` attribue des niveaux de priorité de A (le plus élevé) à D. Une correspondance dans le titre passe avant une correspondance dans le corps, même lorsque les deux contiennent le terme recherché, ce qui correspond au comportement attendu par les utilisateurs. L'API complète et le modèle de pondération à quatre niveaux sont détaillés dans la [référence de la recherche plein texte Django](https://docs.djangoproject.com/en/5.2/ref/contrib/postgres/search/). ## Index GIN et SearchVectorField pour une recherche rapide La requête précédente recalcule le vecteur de recherche pour chaque ligne à chaque requête, ce qui convient pour quelques milliers de lignes mais devient inacceptable au million. Le pattern de production stocke le vecteur dans un `SearchVectorField` et l'indexe avec un index GIN, le type d'index que PostgreSQL utilise pour les valeurs composites comme les documents plein texte et le JSONB. ```python # models.py from django.contrib.postgres.search import SearchVectorField from django.contrib.postgres.indexes import GinIndex from django.db import models class Article(models.Model): title = models.CharField(max_length=255) body = models.TextField() # Document de recherche stocké et pré-calculé search_vector = SearchVectorField(null=True) class Meta: indexes = [ # GIN transforme les recherches plein texte en parcours d'index logarithmiques GinIndex(fields=["search_vector"]), ] ``` Le champ stocké doit rester synchronisé avec `title` et `body`. Un signal `post_save` qui écrit le vecteur via `.update()` évite de déclencher le signal de manière récursive, car `QuerySet.update()` n'émet pas de signaux de sauvegarde. ```python # signals.py from django.contrib.postgres.search import SearchVector from django.db.models.signals import post_save from django.dispatch import receiver from .models import Article @receiver(post_save, sender=Article) def sync_search_vector(sender, instance, **kwargs): Article.objects.filter(pk=instance.pk).update( search_vector=SearchVector("title", weight="A") + SearchVector("body", weight="B"), ) ``` Pour les équipes sous Django 5.0 ou plus récent, un `GeneratedField` calculé par la base ou un trigger PostgreSQL supprime totalement l'UPDATE supplémentaire en maintenant la colonne au sein de la base. Quel que soit le mécanisme qui la remplit, la requête filtre ensuite directement le champ indexé, et l'index GIN transforme ce qui était un parcours complet de table en une recherche logarithmique. > **Les index GIN coûtent cher en écriture** > > Les index GIN accélèrent les lectures au prix réel des écritures, car chaque insertion touche de nombreuses entrées d'index. Sur les tables à fort volume d'écriture, il faut régler le paramètre de stockage `fastupdate` et surveiller la taille de la liste d'attente, sous peine de voir les écritures plein texte bloquées derrière la maintenance de l'index. ## Choisir le bon type d'index PostgreSQL Le B-tree est le choix par défaut le plus adapté, mais PostgreSQL propose plusieurs types d'index via `django.contrib.postgres.indexes`, et opter pour le mauvais gaspille de l'espace disque sans améliorer la moindre requête. Le choix dépend de la forme des données et de l'opérateur utilisé par la requête, comme le recense la [documentation des types d'index PostgreSQL](https://www.postgresql.org/docs/current/indexes-types.html). | Type d'index | Idéal pour | Classe Django | |------------|----------|--------------| | B-tree | Égalité et plage sur colonnes scalaires | `models.Index` | | GIN | Recherche plein texte, JSONB, appartenance à un tableau | `GinIndex` | | BRIN | Tables énormes en ajout seul, ordonnées par une colonne | `BrinIndex` | | Hash | Recherches par égalité seule sur colonnes volumineuses | `HashIndex` | Le BRIN mérite une attention particulière pour les données de séries temporelles. Sur une table de dizaines de millions de lignes insérées dans l'ordre chronologique, un `BrinIndex` sur `created_at` occupe quelques kilo-octets là où un B-tree en réclamerait des centaines de méga-octets, car le BRIN ne stocke que les valeurs minimale et maximale par plage de blocs. En contrepartie, le BRIN n'aide que lorsque l'ordre physique des lignes suit la colonne indexée, ce qui se vérifie naturellement pour les journaux en ajout seul et les tables d'événements. ```python # models.py from django.contrib.postgres.indexes import BrinIndex from django.db import models class Event(models.Model): payload = models.JSONField() created_at = models.DateTimeField(auto_now_add=True) class Meta: indexes = [ # Index minuscule pour parcours par plage sur une table en ajout seul, ordonnée dans le temps BrinIndex(fields=["created_at"]), ] ``` Adapter le type d'index au pattern d'accès est un thème récurrent des entretiens seniors, car cela prouve qu'un candidat raisonne sur l'organisation du stockage plutôt que d'ajouter par réflexe des B-tree partout. ## Questions d'entretien Django sur les bases de données en 2026 Les questions sur les bases de données dominent les entretiens Django seniors, car maîtriser l'ORM n'implique pas de comprendre ce que l'ORM produit réellement. Les questions ci-dessous reflètent ce que les jurys de recrutement sondent effectivement en 2026, et elles se complètent naturellement avec l'optimisation au niveau des requêtes abordée dans le [guide d'optimisation des requêtes de l'ORM Django](/blog/django/django-orm-optimizing-queries). **Quand un index composite sur `(a, b)` n'aide-t-il pas une requête ?** Lorsque la requête ne filtre pas sur `a`. Un B-tree est ordonné d'abord par sa colonne de tête, donc un filtre sur `b` seul ne peut exploiter l'index. C'est la règle du préfixe le plus à gauche, et les candidats qui l'expliquent démontrent qu'ils comprennent la structure d'un index plutôt que des recettes apprises par cœur. **Quelle est la différence entre `select_related` et `prefetch_related` ?** `select_related` effectue une jointure SQL et convient aux relations de clé étrangère et un-à-un en une seule requête. `prefetch_related` lance une seconde requête et assemble les résultats en Python, ce qui est nécessaire pour les relations plusieurs-à-plusieurs et les clés étrangères inverses. Choisir le mauvais des deux fait manquer l'optimisation ou déclenche une seconde requête inutile. **En quoi `icontains` diffère-t-il de la recherche plein texte au niveau de la base ?** `icontains` se compile en `ILIKE '%term%'`, qui ne peut utiliser un index B-tree standard et parcourt chaque ligne. La recherche plein texte s'appuie sur un `tsvector` pré-calculé adossé à un index GIN et classe les résultats par pertinence. Cette distinction est un signal fiable qu'un candidat a dépassé le stade des jeux de données jouets. **Pourquoi `count()` peut-il être lent sur une grande table, et quelles sont les alternatives ?** PostgreSQL ne conserve aucun décompte de lignes en cache pour une table, donc `COUNT(*)` parcourt chaque ligne visible sous MVCC. Pour un décompte approximatif, interroger `pg_class.reltuples` renvoie une estimation instantanée, et pour la pagination, la pagination par clé (keyset) sur une colonne indexée évite tout décompte. Une pratique structurée de ces patterns est disponible dans le [module d'entretien sur le cache Django](/technologies/django/interview-questions/django-caching), où les compteurs adossés au cache reviennent régulièrement, ainsi que sur l'ensemble du [parcours d'apprentissage Django](/technologies/django). **Qu'est-ce que `EXPLAIN ANALYZE` révèle que `EXPLAIN` seul ne montre pas ?** `EXPLAIN` affiche le coût estimé par le planificateur ; `EXPLAIN ANALYZE` exécute réellement la requête et rapporte les temps et nombres de lignes réels. Un écart important entre les lignes estimées et réelles trahit des statistiques de table obsolètes, corrigeables avec `ANALYZE`, et constitue une cause fréquente du planificateur ignorant un index qui devrait manifestement s'appliquer. ## Conclusion - Définir les index dans `Meta.indexes` plutôt qu'avec `db_index=True`, afin que les index composites, partiels et couvrants figurent à un seul endroit auditable. - Ordonner les colonnes des index composites selon la règle du préfixe le plus à gauche : la colonne de tête doit apparaître dans le filtre de la requête pour que l'index s'applique. - Recourir aux index partiels lorsque les requêtes ne visent qu'une minorité de lignes, comme les commandes en attente dans une table dominée par l'historique terminé. - Ajouter des index couvrants avec `include` pour activer les index-only scans et éviter l'accès au tas sur les requêtes d'agrégation à forte lecture. - Remplacer la recherche par `icontains` par `SearchVector`, `SearchQuery` et `SearchRank` pour obtenir gratuitement le classement par pertinence et la racinisation. - Stocker le document de recherche dans un `SearchVectorField` adossé à un `GinIndex`, maintenu synchronisé par un signal, un `GeneratedField` ou un trigger de base de données, avant que la recherche plein texte n'atteigne l'échelle de production. - Toujours valider les décisions d'indexation avec `EXPLAIN (ANALYZE, BUFFERS)` ; un index que le planificateur ne sélectionne jamais est une taxe à l'écriture sans aucun bénéfice à la lecture. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/fr/blog/django/django-postgresql-indexing-full-text-search-2026