Diagnosing Streamlit Caching Problems: Stale Data, Hash Errors, Mutations, and Memory Leaks
A diagnostic guide for Streamlit caching: identify stale data, hash errors, mutation bugs, memory leaks, and cache‑miss regressions with ordered checks, concrete fixes, and escalation paths.
30 Jul 2025, 08:10 UTC

Quick Reference: Common Cache Symptoms
| Symptom | Typical Cause | First Check |
|---|---|---|
| Function returns stale data after source change | File watcher disabled or mutable arguments not re‑hashed | Verify runOnSave and clear cache |
UnhashableTypeError or pickle error on decorator | Arguments/return contain DB connections, locks, open handles | Identify offending argument by removing args one‑by‑one |
| Cached object mutates across sessions | Caller modifies returned list/dict/DataFrame in‑place | Search for .append(), .update(), df[col]=… on cached return |
| Process memory grows until OOM | Unlimited max_entries; many large DataFrames accumulate | Monitor RSS with ps or top; log cache size |
| Cache miss despite identical arguments | Volatile attributes (timestamps, random seeds, object ids) in hash | Log argument hashes via wrapper; compare custom hash_funcs |
Condition 1 – Stale Results After Code or Data Changes
Cause
@st.cache_data hashes the function source and all arguments. If the file watcher (runOnSave) is off, Streamlit does not re‑hash when the source file changes. Mutable arguments (e.g., a list passed by reference) also keep the same hash because the object identity does not change.
Ordered Checks
- Open
.streamlit/config.tomland confirmrunOnSave = true(default). - Run
streamlit cache clear(orst.cache_data.clear()in code) and re‑run the app. - If the function receives mutable containers, add a deterministic
hash_funcsmapping, e.g.hash_funcs={list: lambda x: tuple(x)}.
Fixes
- Restart the Streamlit server after enabling
runOnSave. - Decorate with
@st.cache_data(ttl="1h", hash_funcs={list: lambda x: tuple(x)})to force periodic refresh and stable hashing.
Escalation
If stale data persists, inspect the pickle files in ~/.streamlit/cache. Corrupted pickles can be removed manually; the next run will rebuild them.
Condition 2 – UnhashableTypeError / Pickle Errors
Cause
Arguments or return values contain objects that Python cannot hash or pickle: database connections, threading locks, open file handles, or third‑party SDK clients.
Ordered Checks
- Comment out all arguments; add them back one at a time until the error reappears.
- Check the return type – a connection object returned from a cached function will also trigger the error.
Fixes
- Move resource creation outside the cached function and decorate the singleton with
@st.cache_resource. - For arguments that must stay, provide a custom
hash_funcsthat extracts a stable identifier (e.g., connection string).
@st.cache_resource
def get_db_engine():
return create_engine(os.getenv("DB_URL"))
@st.cache_data(hash_funcs={Engine: lambda e: e.url.render_as_string(hide_password=False)})
def run_query(sql: str, engine):
return pd.read_sql(sql, engine)
Escalation
If a third‑party object cannot be hashed, cache only primitive inputs (query string, parameters) and reconstruct the heavy object downstream.
Condition 3 – Unexpected Mutation Across Sessions
Cause
@st.cache_data returns the *same* object reference on every hit. In‑place modifications (list.append, dict.update, df['col'] = …) affect the cached instance for all users.
Ordered Checks
- Search the codebase for methods that mutate the returned value:
.append(),.extend(),.update(), DataFrame column assignment. - Confirm whether the mutation is intentional (e.g., building a UI state) or accidental.
Fixes
- Return an immutable copy:
return df.copy(),return list(cached_list), or rely on the defaultcopy=Truefor DataFrames (Streamlit ≥1.18). - If a third‑party library mutates the object, wrap the call:
return copy.deepcopy(third_party_call()).
Escalation
When mutation originates from code you cannot change, consider switching to @st.cache_resource for that object and manage cloning manually.
Condition 4 – Unbounded Memory Growth
Cause
Default max_entries=None means the cache never evicts. Each distinct argument combination stores a full copy of the return value (often large DataFrames). Over time the process RSS climbs until the container OOM killer terminates it.
Ordered Checks
- Run
watch -n 5 'ps -o pid,rss,cmd -p $(pgrep -f streamlit)'to observe RSS growth. - Add a temporary log line:
logger.info(f"Cache entries: {len(my_func.cache)}")(accessible viamy_func.__wrapped__.cachein 1.28+).
Fixes
- Set a hard limit:
@st.cache_data(max_entries=100, ttl="2h"). - For
@st.cache_resourcesingletons, implement a manual clear on logout or a background timer:st.cache_resource.clear().
Escalation
If memory still grows after limits, profile non‑cached objects (e.g., session state, large uploaded files). Consider moving heavy caches to an external store like Redis.
Condition 5 – Cache Misses Despite Identical Arguments
Cause
Hash includes volatile fields: timestamps, random seeds, object id(), or mutable containers that change identity between calls.
Ordered Checks
- Wrap the cached function to log the computed hash:
def logged(*a, **kw): h = my_func._hash_func(a, kw); logger.debug(h); return my_func(*a, **kw). - Compare hashes across two runs with the same logical input.
Fixes
- Normalize arguments before calling: strip timestamps, convert objects to tuples of primitives.
- Provide deterministic
hash_funcsfor custom classes, e.g.,hash_funcs={MyConfig: lambda c: (c.param1, c.param2)}.
Escalation
If instability originates in an upstream library (e.g., a request object that regenerates a nonce), cache at a higher level – store the raw HTTP response bytes instead of the parsed object.
Practical Verification Steps
- Enable debug logging:
streamlit run app.py --logger.level=debug(requires Streamlit ≥1.28). Cache hits/misses appear asCache hit for …orCache miss for …. - Inspect cache directory:
ls -l ~/.streamlit/cache. Each pickle file corresponds to a cached entry; verify eviction after settingmax_entries. - Load test: Use
locust -f locustfile.py --headless -u 50 -r 10 --run-time 5mto simulate concurrent users and watch RSS stability. - Confirm version:
streamlit version. Caching API stable since 1.18; debug logs since 1.28.
Limitations & Gotchas
st.cache_data.clear()clears the cache for *all* users sharing the same server process.- TTL and
max_entriesare per‑function, not global. - Custom
hash_funcsmust be pure and deterministic across processes; avoidid(),random, or time‑based values. - External caches (Redis, Memcached) are not built‑in; they require manual serialization.
Checklist for a Clean Cache Setup
- ✅
runOnSave = truein.streamlit/config.toml. - ✅ Use
@st.cache_resourcefor connections, models, and other singletons. - ✅ Annotate every
@st.cache_datawithmax_entriesandttlappropriate to data volatility. - ✅ Return copies (
.copy(),list()) or rely on defaultcopy=Truefor DataFrames. - ✅ Provide
hash_funcsfor any non‑primitive argument types. - ✅ Monitor RSS in staging; set alerts if growth exceeds 80 % of container limit.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.