Module 5 — Mise en cache des données et des ressources
Le modèle d'exécution de Streamlit relance le script à chaque interaction, comme rappelé au module 1. Sans précaution, cela signifie qu'un fichier de trois cent mille lignes est relu à chaque déplacement d'un curseur et qu'un modèle de trois cents mégaoctets est rechargé du disque à chaque clic. L'application devient inutilisable au bout de quelques secondes. La mise en cache résout ce problème avec deux décorateurs — st.cache_data et st.cache_resource — dont le choix n'est pas interchangeable. Ce module explique la différence et montre comment appliquer les deux correctement au fil rouge.
Ce que le cache résout
Streamlit maintient, pour chaque fonction décorée, une correspondance entre les arguments d'appel et le résultat renvoyé. À la deuxième invocation avec les mêmes arguments, la fonction n'est pas réexécutée : le résultat mémorisé est renvoyé directement. La mémoire est partagée entre toutes les sessions du même serveur, ce qui multiplie l'effet quand plusieurs utilisateurs consultent la même application.
La clé de cache est calculée à partir de l'empreinte des arguments. Streamlit sait empreindre les types simples — nombres, chaînes, listes, dictionnaires, DataFrames — et lève une erreur explicite pour ce qu'il ne sait pas empreindre, plutôt que de renvoyer silencieusement un résultat périmé. Cette erreur est parfois vécue comme une gêne ; elle est en fait une protection.
cache_data contre cache_resource
Les deux décorateurs se ressemblent au premier abord. Leur différence est fondamentale et se lit dans une seule idée : cache_data renvoie une copie, cache_resource renvoie l'objet lui-même.
st.cache_data convient aux fonctions dites pures qui produisent des données — un DataFrame, un tableau NumPy, une liste, un dictionnaire. À chaque appel avec les mêmes arguments, Streamlit renvoie une copie profonde du résultat mémorisé. La copie garantit qu'une session ne peut pas altérer les données d'une autre, au prix d'un léger surcoût mémoire.
import streamlit as st
import pandas as pd
@st.cache_data(ttl="1h", show_spinner="Lecture des clients...")
def charger_clients(chemin: str) -> pd.DataFrame:
df = pd.read_csv(chemin, parse_dates=["date_entree"])
df["anciennete_mois"] = ((pd.Timestamp.today() - df["date_entree"]).dt.days // 30).astype(int)
return df
clients = charger_clients("donnees/clients.csv")
L'argument ttl="1h" invalide automatiquement le cache après une heure ; max_entries=10 limite le nombre de résultats mémorisés, utile quand les arguments varient. Le spinner s'affiche pendant l'exécution réelle et disparaît quand le cache est utilisé, ce qui donne à l'utilisateur un retour visuel correct.
st.cache_resource convient aux objets qu'il ne faut ni copier ni recréer : une connexion à une base de données, un modèle de machine learning, un client HTTP maintenant une file de connexions. Le décorateur renvoie l'objet lui-même, partagé entre toutes les sessions.
import joblib
@st.cache_resource(show_spinner="Chargement du modèle...")
def charger_modele():
return joblib.load("modeles/resiliation.joblib")
modele = charger_modele()
proba = modele.predict_proba(caracteristiques)[:, 1]
Un modèle de scikit-learn ou un pipeline entier chargé une seule fois par le serveur est ensuite disponible pour toutes les sessions, sans surcoût. C'est la raison pour laquelle une application Streamlit devient utilisable en production plutôt que gagée par ses relectures répétées.
La règle pratique
La confusion entre les deux se paie cher. Utiliser cache_data sur un modèle recopie le modèle à chaque appel, ce qui annule tout le bénéfice de la mise en cache et gaspille la mémoire. Utiliser cache_resource sur un DataFrame partage l'objet entre sessions, et une session qui modifie la copie retournée par pandas modifie le tableau vu par toutes les autres — c'est le pire des bogues, parce qu'il n'est visible qu'en production sous charge.
Le tableau ci-dessous résume la règle :
| Type de retour | Décorateur | Pourquoi |
|---|---|---|
| DataFrame, tableau NumPy, dictionnaire, liste | cache_data | copie pour isoler les sessions |
| Modèle entraîné, connexion BD, client HTTP | cache_resource | objet unique partagé |
| Chaîne, nombre calculé, tuple immuable | cache_data | même règle que les données |
| Verrous, générateurs, itérateurs consommables | ne pas mettre en cache | le partage n'a pas de sens |
Invalider explicitement
Le cache est mémorisé jusqu'à trois événements : le redémarrage du serveur, l'écoulement du ttl, ou un appel explicite à la méthode .clear() de la fonction décorée. Le bouton « Recharger » d'un tableau de bord se code en trois lignes :
if st.button("Recharger les données"):
charger_clients.clear()
st.rerun()
st.rerun() force une réexécution immédiate du script. La combinaison des deux garantit que la lecture reprend à zéro, sans attendre le prochain clic.
Les objets mutables, ce piège discret
Un DataFrame renvoyé par cache_data est une copie ; la modifier dans le corps de l'application n'affecte pas le cache. Un DataFrame renvoyé par cache_resource est le même objet à chaque appel ; le modifier corrompt le résultat de toutes les futures sessions. La règle est simple : ne jamais muter un objet renvoyé par cache_resource. En cas de doute, appeler .copy() sur le DataFrame retourné, ou basculer sur cache_data.
Un autre piège concerne les arguments : Streamlit calcule une empreinte des arguments, donc une fonction dont un argument est un objet non empreignable — un client HTTP, une session SQLAlchemy — lève une erreur au premier appel. La parade est le préfixe _ dans le nom de l'argument, qui indique à Streamlit de ne pas en tenir compte pour la clé :
@st.cache_data
def compter_clients_actifs(_connexion, seuil: float) -> int:
return _connexion.execute(
"select count(*) from clients where score > ?", (seuil,)
).scalar()
La clé de cache dépend alors uniquement de seuil. On l'utilise avec discernement : deux connexions distinctes seront traitées comme identiques, ce qui peut vider une base et remplir l'autre par erreur.
Mesurer le gain
Pour se convaincre que le cache change tout, il suffit d'ajouter un chronomètre à la fonction de chargement :
import time
@st.cache_data
def charger_lourdement():
debut = time.perf_counter()
df = pd.read_csv("donnees/gros_fichier.csv") # 1,2 million de lignes
st.caption(f"Lecture en {time.perf_counter() - debut:.2f} s.")
return df
À la première exécution, le message affiche par exemple Lecture en 3.41 s. ; aux exécutions suivantes, le corps de la fonction n'est plus exécuté, donc le message n'apparaît plus. L'application reprend en moins de 100 ms au lieu de trois secondes. C'est ce basculement qui rend Streamlit exploitable.
Placez tous les @st.cache_resource en tête de fichier, juste après les imports. Ils décrivent les objets partagés du serveur, comme des constantes d'un module. Les @st.cache_data peuvent rester au plus près de leur usage, parce qu'ils appartiennent à la couche d'application. Cette hiérarchie visuelle rend immédiatement clair, à la lecture, ce qui vit une fois pour toute la vie du serveur et ce qui vit pour la durée d'une requête utilisateur.
En résumé
st.cache_datamémorise le résultat d'une fonction pure et en renvoie une copie à chaque appel : parfait pour des DataFrames, tableaux, dictionnaires.st.cache_resourcemémorise un objet unique partagé entre toutes les sessions : parfait pour un modèle, une connexion, un client HTTP.- Un
ttlou unmax_entriesinvalide le cache automatiquement ;.clear()puisst.rerun()le fait à la demande. - Ne jamais muter un objet renvoyé par
cache_resource; préfixer par_les arguments qu'on ne veut pas voir participer à la clé de cache.
Module suivant : l'état de session et les formulaires, pour qu'un score calculé survive au clic suivant.