wp_options : archéologie d’une dette technique sans propriétaire
Quand on reprend un site WordPress qui a vécu — disons trois ou quatre ans, avec son lot de plugins testés, abandonnés, remplacés, et de thèmes qui se sont succédés — on découvre vite que les vrais problèmes ne sont pas dans wp-content/. Ils sont dans la base de données. Plus précisément, dans une table que personne ne regarde jamais : wp_options.
Le mécanisme silencieux qui plombe vos performances
À chaque chargement de page, WordPress exécute une requête équivalente à ceci :
SELECT option_name, option_value
FROM wp_options
WHERE autoload = 'yes';
Langage du code : SQL (Structured Query Language) (sql)
Cette requête rapatrie en mémoire la totalité des options marquées comme « autoloadées » — c’est-à-dire chargées automatiquement à chaque requête, sans qu’on les demande explicitement. L’idée est bonne sur le papier : éviter à PHP de multiplier les requêtes pour des paramètres fréquemment utilisés.
Le problème, c’est que la fonction add_option() de WordPress prend par défaut $autoload = 'yes'. Autrement dit, dès qu’un développeur de plugin écrit add_option('mon_plugin_setting', $valeur), sans réfléchir au paramètre d’autoload, son option intègre la pile chargée à chaque page, ad vitam æternam.
Multipliez ça par les centaines de plugins qui passeront sur un site au fil de sa vie, ajoutez le fait que désinstaller un plugin via l’interface WordPress ne supprime quasi jamais les options qu’il a créées, et vous obtenez ce qu’on observe en pratique : des tables wp_options qui font 5, 10, parfois 20 mégaoctets en autoload pur, dont la moitié appartient à des plugins disparus depuis trois ans.
Le vrai coût : désérialisation et alloptions
Réduire le problème à « une requête SQL un peu lourde » serait passer à côté de l’essentiel. Le coût dominant n’est pas le SELECT lui-même, mais ce que PHP en fait ensuite. Une fois les données rapatriées, WordPress applique systématiquement maybe_unserialize() sur chaque valeur — et c’est cette désérialisation qui consomme le plus de CPU. Plus les options contiennent de structures sérialisées complexes (tableaux imbriqués, objets PHP), plus l’interpréteur transpire, et ce avant même que la moindre ligne de template ne soit rendue.

Quand un object cache (Redis ou Memcached) est en place, le problème change de nature sans disparaître. WordPress empile l’ensemble des options autoloadées dans une entrée de cache unique nommée alloptions. Ce blob est récupéré à chaque exécution PHP et désérialisé entièrement, même si le code n’en utilise qu’une infime partie. Pire : si la taille de ce blob dépasse la limite d’un slot mémoire — 1 Mo par défaut sur Memcached — l’écriture échoue silencieusement et chaque requête repasse par MySQL. L’object cache, censé soulager la base, devient alors un overhead pur : on paie le coût de la tentative d’écriture et celui du fallback SQL.
C’est ce qui rend le sujet structurellement critique : un site avec un autoload obèse n’est pas seulement un site lent, c’est un site qui peut faire échouer silencieusement les optimisations qu’on déploie pour le sauver.
Le diagnostic, en deux requêtes
Pour mesurer l’ampleur du problème sur un site donné :
SELECT
COUNT(*) AS nb_options,
ROUND(SUM(LENGTH(option_value))/1024/1024, 2) AS poids_Mo
FROM wp_options
WHERE autoload = 'yes';
Langage du code : PHP (php)
Au-delà de 1 Mo chargé en autoload, c’est déjà préoccupant. Au-delà de 3 à 4 Mo, c’est critique — chaque page met du temps à se construire avant même qu’une seule ligne de PHP métier ne se soit exécutée.
Pour identifier les coupables :
SELECT
option_name,
ROUND(LENGTH(option_value)/1024, 2) AS poids_Ko
FROM wp_options
WHERE autoload = 'yes'
ORDER BY LENGTH(option_value) DESC
LIMIT 20;
Langage du code : PHP (php)
Vous serez surpris. Régulièrement, c’est une option de cache qu’un plugin a oublié de purger, une liste sérialisée qui a grossi sans limite, ou un journal d’événements mal pensé. WP-CLI propose une syntaxe plus lisible pour le même usage :
wp option list --autoload=on --orderby=size --order=DESC --format=table
Langage du code : PHP (php)
À ce stade, vous avez une liste. Reste à savoir ce que ces lignes représentent. C’est là que commence le vrai cauchemar.
L’archéologie : décoder ce que Google ne sait pas
Première réaction face à un nom d’option mystérieux : on le tape dans Google. Et là, surprise : aucun résultat. Ou alors quelques fragments de code source sur des dépôts d’archives, sans la moindre documentation.
Cherchez kcseo_wp_schema. Vous trouverez péniblement, après quelques essais, qu’il s’agit du plugin WP SEO Structured Data Schema. Pourquoi kcseo ? Parce que c’est le handle de l’auteur (kcseopro), et parce qu’il a préfixé toutes ses classes PHP avec KcSeo. Le nom commercial du plugin — ce que l’utilisateur voit dans son back-office — n’apparaît nulle part dans le préfixe technique.
Cherchez adt_pfp_. Vous comprendrez, avec un peu d’effort, qu’il s’agit du plugin Product Feed PRO for WooCommerce, édité par AdTribes. adt pour AdTribes, pfp pour Product Feed Pro. Aucun de ces fragments n’apparaît dans le nom commercial du plugin. Pour faire le lien, il faut savoir que l’éditeur s’appelle AdTribes — information qu’on ne trouve qu’en lisant la fiche WordPress.org.
Ce n’est pas anecdotique. C’est systémique. WordPress n’impose strictement aucune convention de nommage à ses plugins. Chaque éditeur invente sa propre logique, et le résultat est un patchwork ingérable.
Typologie des préfixes : sept patterns observés dans la nature
Après avoir audité quelques dizaines de bases existantes, on peut classer les pratiques en sept catégories. De la plus saine à la plus catastrophique :
| Pattern | Logique | Exemple | Décodage |
|---|---|---|---|
| 1 — Nom commercial direct | Le préfixe correspond au nom du plugin | woocommerce_*, yoast_*, elementor_* | Immédiat |
| 2 — Acronyme du nom commercial | Acronyme déchiffrable | aioseo_* (All In One SEO), aiowps_* (All In One WP Security) | Possible avec effort |
| 3 — Abréviation éditeur + abréviation produit | Deux acronymes empilés | adt_pfp_* (AdTribes Product Feed Pro) | Difficile |
| 4 — Handle de l’auteur | Pseudo du développeur | kcseo_* (auteur kcseopro, plugin « WP SEO Structured Data Schema ») | Très difficile |
| 5 — Préfixes multiples coexistants | Refonte ou évolution mal nettoyée | yit_*, yith_*, YITH_WAPO_* (tous YITH) | Confus |
| 6 — Préfixe hérité | Plugin racheté gardant son ancien préfixe | Variable | Intraçable sans archéologie |
| 7 — Aucun préfixe identifiant | wp_subscribers, wp_logs, etc. | — | Impossible |
YITH mérite d’ailleurs un encadré particulier : ils utilisent simultanément yit_ (préfixe historique), yith_ (préfixe actuel), YITH_WAPO_ (préfixe spécifique au module add-ons), et même des variantes en kebab-case comme yith-wcqv-. Le tout coexistant dans la même base de données. Quand on désinstalle un plugin YITH, identifier toutes les options à purger relève de l’enquête criminelle.
La pollution à deux étages
Le problème ne s’arrête pas à wp_options. Les plugins créent aussi des tables custom — wp_xyz_logs, wp_xyz_data, etc. — qui survivent allègrement à toute désinstallation si le plugin n’a pas implémenté un uninstall.php propre.
Et là, le problème change d’échelle :
- Une option oubliée pèse quelques kilo-octets et plombe le TTFB.
- Une table oubliée peut peser plusieurs gigaoctets, plomber les sauvegardes (
mysqldumpinterminable), ralentir les opérations de maintenance MySQL, et compliquer toute migration ou clonage de site.
Les conventions de nommage des tables suivent exactement les mêmes patterns chaotiques que les options. On retrouve les sept catégories ci-dessus, à la nuance près que certaines tables n’ont aucun préfixe identifiant au-delà du wp_ standard. Tomber sur une table wp_logs de 800 Mo dans une base reprise est une expérience récurrente. Personne ne sait à qui elle appartenait. Le plugin a disparu il y a deux ans. La table reste.
La cause profonde : double défaillance
Pourquoi en sommes-nous là ? Deux raisons se cumulent.
Première raison : un défaut culturel chez les développeurs de plugins. L’écosystème WordPress, par sa facilité d’accès, a longtemps attiré une population de développeurs hétéroclite, du professionnel rigoureux à l’autodidacte qui copie-colle des bouts de code. Beaucoup ne se sont jamais posé la question du cycle de vie de leurs données. Pour eux, « désinstaller » un plugin signifie le désactiver. Ce qui reste en base ne les concerne pas. Quant à préfixer leurs options selon une convention compréhensible par un tiers, c’est un détail de confort qu’ils n’imaginent pas avoir d’impact sur quiconque.
Seconde raison : un défaut architectural de WordPress lui-même. Ce n’est ni le premier ni le dernier symptôme d’un CMS qui montre les limites de son modèle historique. Comparez avec des écosystèmes plus disciplinés :
- Composer impose un namespacing strict (
vendor/package). - npm valide les noms de packages et gère les dépendances explicitement.
- Maven force un schéma
groupId:artifactId:version.
WordPress, depuis vingt ans, laisse chaque plugin nommer ses options et ses tables comme bon lui semble. La fonction add_option() met par défaut autoload = 'yes', sans avertissement, sans documentation incitant à la prudence. Le hook register_uninstall_hook() existe mais n’est obligatoire pour personne. Le fichier uninstall.php est facultatif. Et l’API ne fournit aucun mécanisme de découverte permettant à l’administrateur de savoir, après coup, à quel plugin appartenait une option ou une table donnée.
Autrement dit : WordPress fournit les outils du nettoyage, mais ne contraint personne à les utiliser, et n’offre aucun mécanisme de traçabilité quand ils ne l’ont pas été.
Ce que font les plugins qui ne pourrissent pas
Tous les développeurs ne sont pas négligents. Une minorité applique des bonnes pratiques qui, mises bout à bout, suffiraient à régler l’essentiel du problème si elles étaient généralisées :
- Implémenter
register_uninstall_hook()ou fournir un fichieruninstall.php. Le mécanisme officiel existe pour purger options, tables et autres traces lors de la désinstallation. Le code est trivial à écrire. Le mettre en place demande dix minutes. - Utiliser le troisième paramètre de
update_option(). La signature complète estupdate_option($option, $value, $autoload = null). Passer explicitementfalsequand l’option n’a pas vocation à être chargée à chaque page, c’est faire preuve d’un minimum de respect pour ses utilisateurs et leurs serveurs. - Privilégier les transients et l’object cache. Pour les données volatiles ou volumineuses (caches, agrégats, résultats de calculs), le couple
set_transient()/get_transient()ou un object cache externe (Redis, Memcached) sont infiniment plus appropriés quewp_options— étant entendu qu’en l’absence de cache d’objet, les transients atterrissent eux-mêmes danswp_options. Un plugin qui crée des centaines de transients sans expiration (ou mal gérés) participe directement à l’obésité de la table, même s’ils ne sont pas en autoload. La discipline d’expiration et le nettoyage périodique sont aussi essentiels que le choix de l’API. - Externaliser les gros objets. Les plugins de cache ou d’analytics récents adoptent une approche « single option pointer » : la table
wp_optionsne stocke qu’un identifiant, et les données réelles sont rangées dans une table dédiée ou un fichier sur disque, hors du chemin critique de l’autoload. - Documenter les options créées. Une section dédiée dans le
readme.txtlistant lesoption_nameintroduits par le plugin permet à n’importe quel administrateur de tracer les responsabilités, des années plus tard, quand le plugin aura disparu et que son auteur ne répondra plus.
Aucune de ces pratiques n’est exotique. Aucune n’exige un effort significatif. Toutes existent dans la documentation officielle. Le problème est que rien n’oblige personne à les appliquer — et que la rigueur reste l’exception, pas la règle. C’est précisément la discipline que je m’efforce d’appliquer sur mes propres plugins, qu’il s’agisse de WP4Odoo ou de WooCommerce Subscriptions Tax Retrofit : préfixe cohérent avec le slug officiel, uninstall.php qui purge effectivement, troisième paramètre de update_option() posé en conscience. Ce n’est pas du zèle, c’est le minimum qu’on doit aux administrateurs qui hériteront de ces traces dans dix ans.
Méthodologie d’audit, à défaut de solution miracle
Soyons honnête : il n’existe pas d’outil qui résolve magiquement le problème. Les plugins existants — Advanced Database Cleaner, WP-Sweep, WP-Optimize — fonctionnent par heuristique : ils tentent de matcher les préfixes d’options aux plugins actifs, et flaggent les « orphelins ». Mais leur détection a des faux positifs et, surtout, elle ne peut pas savoir avec certitude qu’une option est inutile. Le risque de casser quelque chose est réel.
La méthode que j’applique sur les sites repris :
- Audit chiffré. Mesurer le poids autoload total et lister les vingt plus gros contributeurs.
- Croisement avec les plugins actifs. Pour chaque préfixe inconnu, tenter d’identifier le plugin (recherche de fragments du nom dans les fichiers
wp-content/plugins/actifs et inactifs). Si rien ne correspond, suspecter l’orphelin. - Recherche manuelle dans les dépôts publics. Quand le préfixe ne donne rien sur Google, des sites comme pluginarchive.com, github.com ou le SVN WordPress.org permettent parfois de remonter à un plugin disparu.
- Sauvegarde avant toute suppression. Toujours. Y compris pour les options qui semblent évidemment orphelines.
- Suppression progressive avec test. Désautoloader d’abord (
autoload = 'no'), observer le comportement du site sur quelques jours, puis supprimer. - Audit similaire des tables custom.
SHOW TABLE STATUSpour identifier les tables anormalement grosses, croisement avec les plugins actifs, vérification de la dernière date de modification. - Traque des transients orphelins. WordPress n’auto-purge pas systématiquement les transients dont l’expiration est dépassée. Une requête de comptage permet de mesurer l’ampleur du résidu :
SELECT COUNT(*) FROM wp_options WHERE option_name LIKE '_transient_timeout_%' AND option_value < UNIX_TIMESTAMP();Si le compteur dépasse la centaine, un nettoyage s’impose —wp transient delete --expiredrègle ça en une commande.
C’est artisanal, c’est lent, et ce n’est pas glamour. Mais c’est ce qui marche.
Ce qu’il faudrait, et qui n’arrivera probablement jamais
Une vraie solution exigerait que WordPress impose, ou à tout le moins recommande fortement, un standard de la forme :
- Un préfixe d’option et de table cohérent avec le slug officiel du plugin.
- Un manifest déclaratif dans le
readme.txtlistant les options et tables créées. - Un mécanisme de découverte permettant de retrouver, à partir d’une option orpheline, le plugin qui l’a créée.
- Un
uninstall.phpobligatoire ou auto-généré à partir du manifest.
Aucune de ces évolutions n’est techniquement complexe. Toutes sont culturellement improbables : WordPress a fait de sa permissivité une marque de fabrique, et toute contrainte imposée aux développeurs tiers ferait hurler une partie de la communauté. C’est précisément cette permissivité qui a permis l’essor de l’écosystème, et qui en fait aujourd’hui le coût caché. Les tentatives de refonte qui émergent ailleurs — EmDash, le CMS sandboxé que Cloudflare construit pour l’ère des agents IA, entre autres — partent précisément du constat qu’on ne peut plus laisser un plugin tiers écrire ce qu’il veut, où il veut, sans traçabilité.
En attendant qu’une telle norme émerge — autant dire jamais — il reste l’archéologie, la requête SQL bien sentie et la patience. Tant que WordPress restera une maison ouverte à tous les vents, wp_options restera le grenier où s’entassent les souvenirs des anciens locataires.