cswork / libraryOpen practice workspace

Articles

Streamlit Interview Questions: Reruns, Session State, and Cache Boundaries

Trace Streamlit reruns, Session State, callbacks, and cache ownership with worked examples, tenant-isolation checks, and targeted interview practice.

A Streamlit interview can turn on one small surprise: your counter worked when you clicked Add, then returned to zero when you moved a slider. Fixing the display is only the first step. You also need to explain which code reran, which value survived, and whether a cache reused somebody else's object or somebody else's data.

The useful distinction is between execution, ownership, and identity. A rerun executes code again. Session State retains values for one session. A cache reuses a result according to its key and scope. Those mechanisms solve different problems, and confusing them can produce both stale screens and cross-user incidents. Start practicing that diagnosis with Debug a cache incident end-to-end.

Evidence boundary: Official behavior below comes from Streamlit's documentation checked October 1, 2026; the current cache references display version 1.64.0. The exercises and recommended tests are original teaching material and engineering inference. They are not candidate reports, employer interview questions, or production benchmarks. Check your installed version before using newer cache-scope options.

What actually reruns when a widget changes?

For a normal widget outside a form or fragment, an interaction causes the script to run again from top to bottom. Ordinary local assignments run again too. The architecture guide explains this default execution model; forms and fragments introduce specific exceptions. Avoid saying that every interaction always executes every line in every Streamlit app. Streamlit caching architecture.

Consider this original broken counter:

import streamlit as st

count = 0
if st.button("Add"):
    count += 1
st.slider("Unrelated setting", 0, 10)
st.write(count)

On the initial run, count is zero. Clicking Add produces a new run in which the button is true and the displayed count becomes one. Moving the slider produces another run: the assignment resets the local variable, the button is no longer clicked, and the output returns to zero.

The problem is not that the slider subtracted one. The program rebuilt an ephemeral value. A good interview answer describes the event and the relevant execution path before proposing a global variable. A module-level shared counter would introduce an ownership problem: whose count should it represent?

Use this prediction table before running the repaired version:

InteractionBroken local counterSession counter
Open a fresh session00
Click Add11
Move the unrelated slider01
Click Add again12

The values assume the same active session, no explicit clearing, and the small app shown here. A page reload that creates a new session is a different test. Naming those conditions prevents an apparently correct trace from hiding its lifecycle assumptions.

How does Session State repair the counter?

Session State provides storage between reruns for a user's session. Its API reference also makes the boundary explicit: state is tied to the WebSocket connection, and reconnecting through a browser reload or some navigation can reset it. It is not a durable database or a universal identifier for a person. Session State API.

import streamlit as st

if "count" not in st.session_state:
    st.session_state.count = 0

def increment():
    st.session_state.count += 1

st.button("Add", on_click=increment)
st.slider("Unrelated setting", 0, 10, key="noise")
st.write(st.session_state.count)

The conditional initialization matters. Assigning zero on every run would erase the value you intended to retain. The callback changes the retained count, and the subsequent run reads that count to render the screen.

Now test two sessions. Add twice in session A, then inspect session B. B should still show zero. That assertion addresses ownership, whereas the slider test addresses survival across reruns. They are separate properties and deserve separate evidence.

For a saved analysis, ask what must survive a disconnect. A selected tab can live in Session State; a report that a customer expects to recover tomorrow should have a durable record and an explicit identifier. Store only the data needed for the feature. Do not treat a retained Python object as proof that a transaction was committed.

When do callbacks and forms change the timeline?

The forms guide describes the ordering: the backend receives updated widget values, the callback runs as a prefix to the page rerun, and the rerun renders using the updated state. In a form, edits are sent together when the form is submitted; only the submit button can have a callback inside that form. Using forms.

Suppose an interview exercise asks for a report with a date range and a region. Without a form, changing either input can trigger work against a half-edited configuration. With a form, the user can edit both before submitting one coherent request.

A useful original design separates draft widget values from a committed request. On submit, copy the submitted date range and region into a small report_request record. Render the existing report from the committed record until a new submission succeeds. This makes the UI's meaning clear: the screen shows the last accepted request, not necessarily every currently visible draft input.

For callbacks, read newly submitted values through keyed entries in Session State. Passing a widget value as a callback argument while constructing the widget can capture the value from that earlier run. The official forms guide specifically recommends looking up the new value inside the callback.

Explain errors as transitions too. If the submitted end date precedes the start date, retain the last good report and show the validation error. If a query fails, do not silently label the old output as the new request. That behavior is a design recommendation; Streamlit cannot infer your product's submission contract.

What is the difference between data and resource caches?

st.cache_data caches serializable results and gives each caller its own copy. The current API also documents global and session scopes, with global as the default. A copied result protects the cached value from ordinary caller mutation; it does not make the query's cache key correct. Data-cache API.

st.cache_resource retains a resource object that callers can share and mutate. Globally shared resources must be thread-safe. The current 1.64.0 reference also supports scope="session"; older deployments should verify their own API before adopting that argument. Resource-cache API.

Use this original mutation experiment:

import streamlit as st

@st.cache_data
def data_values():
    return [1, 2]

@st.cache_resource
def resource_values():
    return [1, 2]

first_data = data_values()
first_data.append(99)
assert data_values() == [1, 2]

first_resource = resource_values()
first_resource.append(99)
assert resource_values() == [1, 2, 99]

Run it against freshly cleared function caches. Repeatedly running the mutation block adds more values to the resource list, so this is a deliberately isolated experiment rather than a reusable app body. The interview point is object ownership, not a recommendation to cache mutable lists as global resources.

For a database client, distinguish the shared client or pool from an individual transaction. A thread-safe pool does not automatically make a borrowed connection, cursor, or transaction safe to reuse across users. Give each operation its own appropriate transactional scope. For a model, ask whether inference mutates internal buffers or other state before calling it thread-safe.

Can a correct cache still return the wrong tenant's data?

Yes. Consider this original sketch:

@st.cache_data
def fetch_rows(query, _client):
    return _client.execute(query)

An underscore-prefixed argument is excluded from Streamlit's argument hashing, as documented by the data-cache reference. If two tenant-specific clients run the same query string, _client alone does not distinguish their cached results. Returning a copy of the first result does not repair that identity mistake.

Trace a concrete incident. Tenant A requests select total from summary and populates a cache entry. Tenant B supplies a different client but the same hashed query argument. The cache can reuse A's result. Each caller receives a separate Python copy, yet B's copy still contains A's information.

Our recommended repair starts with a complete semantic key: tenant identifier, normalized request parameters, relevant permission-policy version, and data revision where the product needs that distinction. Authorize the request before returning a result, and ensure the query executes against the tenant represented by the key. Merely adding an unverified tenant_id string that does not control the actual query creates a misleading label.

A test should request identical report parameters for tenants A and B with intentionally different seeded totals. Assert that B receives B's total, then change B's permissions and repeat. Test both cache hits and misses. Clearing the cache once can hide the defect; it does not establish isolation.

How should you explain freshness and cache invalidation?

Define what stale means before choosing a TTL. For an illustrative internal dashboard, suppose a report may lag source ingestion by five minutes but permission revocation must take effect immediately. A single five-minute expiration policy cannot express both requirements by itself.

Use separate mechanisms: data freshness can use a revision or bounded cache lifetime, while authorization is checked against its required current policy. A report label can state the data revision and observation time. This is an engineering design, not a universal Streamlit guarantee.

Also distinguish deleting a cache entry from undoing a side effect. If a cached function sends an email or charges a customer, a cache hit changes whether that action executes; clearing the cache changes it again. Such operations need their own idempotency and persistence contract. Caching is easiest to reason about when the function computes or fetches a result with explicit inputs.

For the interview, propose a bounded experiment: count function executions, make the same request twice, vary one key argument, and then clear that function's cache. Record both the output and execution count. A screenshot of unchanged output cannot prove that computation was skipped.

Sources and Further Reading

Sources checked October 1, 2026. Confirm cache-scope support against the deployed Streamlit version; all counter, mutation, and tenant scenarios are original exercises.