Un index partiel ne couvre qu'une partie des lignes
Un index classique contient une entrée pour chaque ligne de la table. Un index partiel, lui, ne référence que les lignes qui satisfont la clause WHERE indiquée à sa création. Résultat : un index plus compact, moins de pages à parcourir et moins de travail à chaque INSERT ou UPDATE qui ne concerne pas la portion indexée.
La syntaxe reste un CREATE INDEX tout ce qu'il y a de plus classique, avec un WHERE ajouté à la fin :
idx_orders_pending ne contient que les lignes où status = 'pending'. Les commandes expédiées, annulées ou remboursées en sont totalement absentes. Si 95 % de votre table orders correspond à de l'historique et que vos requêtes ciblent surtout les commandes ouvertes, on parle d'un index 20× plus petit pour la même vitesse de requête.
Quand le planificateur va réellement l'utiliser
Un index partiel n'est exploitable que si SQLite peut prouver que votre requête se limite aux mêmes lignes que celles couvertes par l'index. Le plus simple : reprendre la clause WHERE de l'index dans votre requête :
Le plan doit mentionner USING INDEX idx_orders_pending. Si vous retirez status = 'pending' de la requête, le planificateur retombe sur un parcours complet de la table — il n'a aucun moyen de savoir que la requête reste cantonnée au sous-ensemble indexé.
La règle à retenir : le WHERE de la requête doit impliquer le WHERE de l'index. Une égalité sur la même colonne avec la même valeur, c'est le cas évident et sans risque. Les inégalités et les OR, c'est plus délicat ; vérifiez toujours avec EXPLAIN QUERY PLAN.
Pourquoi s'embêter — les trois gains
Trois raisons concrètes qui justifient l'usage d'un index partiel :
- Moins d'espace disque. Seules les lignes qui matchent sont stockées. Sur un scénario où « 1 % de la table est chaude », l'index pèse environ 1 % d'un index complet.
- Écritures moins coûteuses. Les
INSERTetUPDATEne touchent à l'index que si la ligne satisfait le filtre. UnINSERTavecstatus = 'shipped'sur la table ci-dessus ne touche pas du tout àidx_orders_pending. - Vitesse de lecture identique. Une recherche dans un B-tree est logarithmique par rapport à la taille de l'index. Plus l'index est petit, plus la recherche est (légèrement) rapide, mais le vrai bénéfice est ailleurs : moins de cache miss, moins d'I/O.
Si une colonne est très déséquilibrée — la majorité des lignes ont la même valeur et vous ne vous intéressez qu'aux quelques exceptions — c'est le cas d'école pour un index partiel.
Index uniques partiels (la fonctionnalité qui change tout)
Une contrainte UNIQUE classique s'applique à toutes les lignes. Et ça devient un vrai problème dès qu'on introduit le soft delete :
-- Échoue : il y a deux lignes avec email = 'a@x.com', même si l'une est supprimée.
CREATE UNIQUE INDEX idx_users_email ON users(email);
Un index partiel unique vous permet de garantir l'unicité uniquement sur les lignes qui comptent vraiment :
Trois lignes, le même e-mail, et aucune violation de contrainte — parce que seule la ligne où deleted_at IS NULL entre dans la vérification d'unicité. Essayez d'insérer une deuxième ligne active avec le même e-mail : SQLite lèvera aussitôt UNIQUE constraint failed.
Ce schéma revient partout : un seul abonnement actif par client, une seule adresse principale par utilisateur, une seule facture ouverte par commande. L'index partiel unique exprime cette règle de façon directe.
Indexer autour des NULL
Les NULL se comportent bizarrement vis-à-vis des index. Un besoin classique consiste à « ignorer complètement les NULL » : imaginez une colonne external_id peu remplie, où la plupart des lignes sont à NULL, mais où les valeurs renseignées doivent rester uniques.
Deux NULL cohabitent sans souci, et les lignes EXT-001 et EXT-002 restent garanties uniques. L'index occupe aussi moins de place — les lignes NULL ne sont tout simplement pas stockées — ce qui rend les recherches par external_id rapides, même quand la table grossit.
Ce que la clause de filtrage peut référencer
La clause WHERE d'un index partiel sqlite est restrictive. Elle peut faire référence à :
- Des colonnes de la table indexée.
- Des constantes littérales.
- Un petit ensemble de fonctions intégrées déterministes.
En revanche, elle ne peut pas référencer :
- D'autres tables.
- Des sous-requêtes.
- Des fonctions non déterministes comme
random()ouCURRENT_TIMESTAMP. - Des paramètres ou des variables.
Et c'est logique : SQLite doit évaluer ce filtre à chaque INSERT et UPDATE, donc le résultat doit rester stable. Du coup, ceci fonctionne :
En revanche, WHERE created_at > date('now') ne convient pas : la valeur de date('now') évolue avec le temps, donc l'ensemble des lignes indexées changerait au fil de l'eau, ce que SQLite ne sait pas gérer.
Une méthode de vérification rapide
Avant de valider un index partiel, passez-le au crible de ces trois questions :
La requête 1 doit utiliser idx_jobs_runnable. Les requêtes 2 et 3 retomberont sur un parcours complet (ou sur un autre index, si vous en avez un). Si le planificateur choisit l'index partiel pour une requête à laquelle vous ne vous attendiez pas, relisez le filtre — il est sans doute plus large que ce que vous pensiez.
Quand éviter l'index partiel
L'index partiel est un outil tranchant. Voici quand il vaut mieux passer son chemin :
- Le filtre couvre la majorité de la table. Si « actif » représente 90 % de vos lignes, votre index partiel n'est qu'un index classique avec des étapes en plus. Indexez simplement la colonne.
- Vos requêtes ne reproduisent pas le filtre tel quel. Si votre code passe par un ORM qui génère
WHERE status IN (?, ?, ?), ou qui construit le filtre dynamiquement, le planificateur ne reconnaîtra souvent pas la correspondance. Vérifiez avecEXPLAIN QUERY PLAN, ne supposez rien. - Le sous-ensemble chaud bouge dans le temps. Un index partiel sur « les commandes des 30 derniers jours » paraît séduisant, mais ce n'est pas exprimable — le filtre doit être déterministe. Il faudrait reconstruire l'index, ou repenser le schéma (une table dédiée
recent_orders, ou un booléenarchivedque vous basculez chaque nuit).
Quand le filtre est stable et ne touche qu'une petite tranche d'une grosse table, l'index partiel fait partie des optimisations au meilleur rapport effort/gain dans SQLite.
Et ensuite : lire les plans d'exécution
Toute cette page s'est appuyée sur EXPLAIN QUERY PLAN pour confirmer qu'un index était bel et bien utilisé. Cet outil mérite sa propre page : comment lire sa sortie, ce que veulent dire les mots-clés, et comment repérer la différence entre une vraie recherche par index et un parcours complet déguisé. C'est ce qu'on verra ensuite.
Questions fréquentes
C'est quoi un index partiel en SQLite ?
Un index partiel n'indexe que les lignes qui satisfont une clause WHERE donnée à la création. On écrit CREATE INDEX nom ON table(col) WHERE condition et SQLite ne stocke d'entrées que pour les lignes où la condition est vraie. Résultat : un index plus petit, des écritures plus rapides, et la même vitesse de lecture pour les requêtes qui correspondent au filtre.
Quand préférer un index partiel à un index complet ?
Quand vous interrogez en boucle une petite tranche d'une grosse table : commandes en attente, utilisateurs actifs, jobs non traités. En n'indexant que cette tranche, l'index reste minuscule et les écritures sur les autres lignes ne le touchent pas. Attention : si vos requêtes ne reprennent pas la même condition WHERE que l'index, le planificateur ne pourra pas l'utiliser.
Un index partiel peut-il imposer une contrainte d'unicité ?
Oui. CREATE UNIQUE INDEX ... WHERE ... applique l'unicité uniquement sur les lignes qui matchent le filtre. Le cas d'usage classique, c'est « un seul enregistrement actif par utilisateur » : les lignes soft-deleted sont exclues, donc on peut avoir plusieurs entrées supprimées avec la même clé, mais une seule active.