Controlling Data Freshness in Streamlit with st.cache_data TTL
Avoid re-running expensive queries on every Streamlit interaction by using st.cache_data with a tuned TTL and hashable arguments.
30 Jun 2026, 00:44 UTC

Problem: Streamlit apps re-run data loading on every interaction
Streamlit apps automatically re-execute Python code whenever a widget changes or the page re-renders. For functions that load databases, call external APIs, or perform heavy calculations, this behavior can cause noticeable latency and unnecessary resource usage.
Useful takeaway
Decorate any data-returning function with @st.cache_data(ttl=) and ensure all arguments are hashable. Streamlit will memoize the result and automatically expire it after the specified time, refreshing the data on the next call. When inputs come from external sources, append a version string or hash to the function arguments so the cache invalidates intentionally.
How st.cache_data works under the hood
When you decorate a function with @st.cache_data, Streamlit wraps it in a memoization layer. On each call, the wrapper:
- Builds a cache key from the function’s positional and keyword arguments.
- Checks whether the key exists in an internal dictionary and whether the stored result is still fresh relative to the decorator’s
ttl(time-to-live) setting. - If the key is present and fresh, the cached result is returned without re-executing the function body.
- If the key is missing or the TTL has elapsed, the function runs, its return value is stored, and the new result is served.
The key is only valid if every argument is hashable — meaning it can be converted to a form that Python’s dictionary can use as an index. Immutable types such as strings, numbers, and tuples are hashable; lists, sets, and raw dictionaries are not.
Worked example: caching a database query with a tuned TTL
import streamlit as st
import json
@st.cache_data(ttl=3600) # cache expires after 1 hour
def fetch_user_profile(user_id, preferences):
# Simulate an expensive operation
import time
time.sleep(2)
# In real code, this would query a DB or API
profile = {'id': user_id, 'prefs': preferences, 'retrieved_at': 'now'}
return profile
# Usage: arguments must be hashable
user_id = 12345
# Convert a dict to a JSON string so it can be used as a cache key
prefs_json = json.dumps(preferences, sort_keys=True) if preferences else 'none'
profile = fetch_user_profile(user_id, prefs_json)
st.write(profile)
In this example, json.dumps with sort_keys=True turns the preferences dictionary into a stable string that st.cache_data can hash. Without this step, passing a raw dict would raise a TypeError because dictionaries are mutable and unhashable.
Making arguments hashable: common patterns
If your function accepts complex inputs, convert them before they reach the decorated function:
- Replace lists with tuples (
tuple(my_list)) - For unordered collections, use
frozenset(my_set) json.dumps(obj, sort_keys=True)works for dicts and nested structures- Convert objects to their string representation if the semantic meaning is preserved
Each pattern ensures the cache key can be built without raising a TypeError.
Limits and common mistakes
- Non‑hashable arguments: Passing a list, set, or unserialized dict directly to a
@st.cache_datafunction raises aTypeError. Always preprocess arguments to an immutable form. - Stale data from external dependencies:
st.cache_dataauto-invalidates when the function’s source code changes, but it does not detect changes in external APIs, database schemas, or file-based data. Append a version number or hash of the data source to the argument tuple to force refresh when needed. - TTL too short: Setting a very low
ttl(e.g.,ttl=5seconds) defeats the purpose of caching and adds overhead from repeated function executions. Choose a TTL that matches your data freshness requirements. - Memory bloat: Caching many large DataFrames or objects without bounds can grow memory usage. For singleton or short-lived objects, consider
st.experimental_singletoninstead.
Verifying cache behavior in your app
To see the cache in action, add a module-level counter and print statements:
calls = 0
def expensive_load(filter_param):
global calls
calls += 1
# Simulate work
import time; time.sleep(1)
return {'calls': calls, 'filter': filter_param}
# First call: runs the function, calls == 1
result1 = expensive_load('active')
# Second call with same argument: cache hit, function body not re-executed
result2 = expensive_load('active')
# Third call with different argument: cache miss, function runs again
result3 = expensive_load('inactive')
Run your Streamlit script and observe the console output or add st.write to display the calls value. The second call with the same filter_param should not increment the counter, demonstrating a cache hit.
For a deeper view, newer Streamlit versions expose cached entries via st.get_cache_data or by inspecting the .streamlit/cache directory on disk. These tools show keys, timestamps, and stored results, helping you confirm that TTL expiration works as expected.
When to consider alternatives
If your function returns a singleton object (e.g., a configured logger or a singleton connection pool) rather than computed data, st.experimental_singleton may be more appropriate because it preserves object identity and does not serialize/deserialize on each cache miss. For data that changes frequently and cannot be versioned via arguments, a manual invalidation strategy—such as clearing the cache with st.cache_data.clear() on a schedule—may be required.
Understanding how st.cache_data builds keys, respects TTL, and requires hashable inputs lets you write Streamlit apps that feel snappy without sacrificing data freshness. Preprocess arguments, tune your TTL, and verify behavior periodically to keep the cache working for you.