50 000 pages, un score d’accessibilité par page, une poignée d’anomalies détaillées par score, et une agence qui veut un tableau de bord filtrable par plage de score, par type de contenu et par date d’audit. La tentation naturelle consiste à stocker tout cela dans wp_postmeta, la table native de métadonnées de WordPress, avec des clés comme _a11y_score et _a11y_issues. Le schéma existe déjà, l’API update_post_meta() est familière, aucune migration de base ne semble nécessaire. C’est précisément ce raisonnement qui, à cette échelle, produit des tableaux de bord d’une lenteur insupportable.
Le problème ne vient pas de WordPress lui-même, mais de la structure de wp_postmeta : une table clé-valeur avec les colonnes meta_id, post_id, meta_key et meta_value, où seule la combinaison post_id/meta_key bénéficie d’un index par défaut. Filtrer ou trier par meta_value, par exemple pour lister les pages dont le score est inférieur à 70, revient à demander à MySQL de parcourir potentiellement l’intégralité de la table, puisque meta_value est typée en longtext et n’est indexée nulle part par défaut.
Ce que révèle EXPLAIN sur une requête de tableau de bord
Une requête typique de filtrage par score ressemble à ceci une fois traduite en SQL brut par WP_Query avec un meta_query :
SELECT p.ID FROM wp_posts p
INNER JOIN wp_postmeta pm ON p.ID = pm.post_id
WHERE pm.meta_key = '_a11y_score'
AND CAST(pm.meta_value AS UNSIGNED) < 70
ORDER BY CAST(pm.meta_value AS UNSIGNED) ASC;
La conversion CAST() appliquée directement dans la clause WHERE empêche MySQL d’exploiter le moindre index sur meta_value, même s’il en existait un : une fonction appliquée à une colonne rend l’index inutilisable pour cette recherche, sauf à créer un index fonctionnel dédié, disponible depuis MySQL 8.0.13. Sur 50 000 lignes de score, associées à d’autres métadonnées WordPress standard dans la même table (souvent plusieurs millions de lignes toutes clés confondues), le scan complet devient perceptible dès la seconde de chargement du tableau de bord.
Deux options structurantes, pas une rustine

La première option, la plus rapide à mettre en œuvre, consiste à créer une table personnalisée dédiée aux audits, avec des colonnes typées et indexées dès la conception :
CREATE TABLE wp_a11y_audits (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
post_id BIGINT UNSIGNED NOT NULL,
score SMALLINT UNSIGNED NOT NULL,
issues_count SMALLINT UNSIGNED NOT NULL DEFAULT 0,
audited_at DATETIME NOT NULL,
KEY idx_post_id (post_id),
KEY idx_score (score),
KEY idx_audited_at (audited_at)
) ENGINE=InnoDB;
Cette table se crée avec dbDelta() lors de l’activation du plugin d’audit, exactement comme WordPress le fait pour ses propres tables. Le score devient une vraie colonne numérique indexée, les filtres et les tris deviennent des opérations directes pour l’optimiseur MySQL, sans conversion de type ni parcours complet.
La seconde option, plus légère si le volume reste raisonnable et si l’agence veut rester strictement dans l’écosystème natif de métadonnées, consiste à ajouter un index composite ciblé directement sur wp_postmeta via une migration :
ALTER TABLE wp_postmeta
ADD INDEX idx_meta_key_value (meta_key(20), meta_value(20));
Cet index composite accélère les recherches d’égalité et les préfixes, mais reste inefficace pour les comparaisons numériques (<, >, BETWEEN) sur une colonne texte : dès qu’il faut trier 50 000 scores ou filtrer une plage, la table dédiée reprend l’avantage.
Ce qu’il ne faut pas faire
- Multiplier les index génériques sur
meta_valueenlongtext: MySQL limite la taille indexable et les performances d’écriture se dégradent sur une table déjà volumineuse et partagée par tout WordPress - Stocker le détail des anomalies (souvent un tableau JSON de plusieurs kilo-octets) dans
meta_valuesans compression ni pagination, ce qui alourdit chaqueSELECT *déclenché ailleurs sur la même table - Interroger la table sans jamais vérifier le plan d’exécution réel avec
EXPLAIN, en se fiant uniquement au temps ressenti en environnement de développement, où le volume de données ne reproduit jamais la charge de production
Ce que change une table dédiée pour l’agence
Avec une table propre, le tableau de bord peut interroger directement l’historique des audits, calculer des moyennes par type de contenu, ou isoler les régressions entre deux dates, avec des requêtes qui restent lisibles :
SELECT post_id, score FROM wp_a11y_audits
WHERE score < 70 AND audited_at >= '2024-01-01'
ORDER BY score ASC LIMIT 50;
Le gain n’est pas seulement une question de vitesse perçue : il conditionne la capacité de l’agence à faire évoluer son tableau de bord (nouveaux filtres, nouveaux graphiques d’évolution) sans repartir d’un schéma de données inadapté à l’échelle visée.
Sur nos projets qui dépassent quelques milliers de contenus audités, la règle est simple : dès que
meta_querycommence à trier ou filtrer numériquement, c’est le signal qu’une table dédiée devient rentable.
Notre verdict
Réutiliser wp_postmeta pour de la donnée strictement liée à un article (une couleur de mise en avant, une préférence d’affichage) reste tout à fait pertinent. Mais dès qu’un volume important de valeurs numériques doit être filtré, trié et agrégé, comme c’est le cas pour des scores d’accessibilité sur 50 000 pages, une table personnalisée indexée dès sa création évite un ralentissement qui, sinon, finit toujours par se manifester au pire moment : celui où le client consulte enfin son tableau de bord.