Streamlit beyond the prototype: production patterns for Python data apps

Caching, session isolation, warehouse connectors, and deployment—the four things that determine whether a Streamlit app is reliable or fragile.

open-source-knowledge

Streamlit is the fastest way to turn a Python function into a shared UI. Most teams discover the limits not when they first deploy it, but six months later when the dashboard is expected by twenty people instead of two, the data source is now a production warehouse instead of a CSV, and the PM wants role-based filtering that the framework was not designed for.

This article covers the four areas where Streamlit prototype assumptions become production liabilities—and the patterns that address them without a full rewrite.

1. Caching: the difference between fast and expensive

Streamlit re-runs your script on every interaction. Without caching, every button click that triggers pd.read_sql(...) sends a query to your warehouse. At low volume this is invisible. At twenty concurrent users it becomes a warehouse bill and a latency problem at the same time.

st.cache_data is for functions that return data—DataFrames, dicts, lists. It serialises the return value and compares it on cache hit. Use it for warehouse queries, API calls, and any pure transformation that depends only on its inputs.

st.cache_resource is for connections and objects that should not be serialised—database connections, model weights, thread-unsafe clients. The return value is shared across sessions. This distinction matters: a database connection pool cached with st.cache_data will fail silently on serialisation; one cached with st.cache_resource will be shared correctly.

TTL is mandatory for production. @st.cache_data(ttl=300) tells Streamlit to re-run the function after five minutes. Without a TTL, the cache lives until the process restarts—so users may see hours-old data with no indication it is stale.

Scope by input parameters. Streamlit caches based on the function arguments. If the query changes with a user-selected date range, the cached value changes correctly. If you embed the date range in a global variable and pass nothing, all users share the same cached result regardless of their filter.

2. Session state: isolation between users

st.session_state is per-session, which sounds safe. The problem is that many Streamlit apps accumulate state in ways that grow unbounded: appended conversation histories, growing DataFrames, uncleaned temporary files. A session that runs for eight hours will consume memory proportional to every interaction. On a shared deployment with twenty users, this becomes an OOM kill.

Set explicit limits: keep only the last N items in conversation lists, reset large DataFrames when the data source changes, and never store raw file uploads in session state beyond the render cycle that needs them.

A second pattern: distinguish between UI state (which filter is selected) and derived state (the filtered DataFrame). Recompute derived state from inputs using st.cache_data—do not store it in session state. This keeps session state tiny and makes the app correct when inputs change.

3. Warehouse integration: separate queries from the render cycle

Streamlit’s script-rerun model means that any slow query in the render path blocks the entire UI thread. A five-second warehouse query runs on every interaction unless you cache it. A ten-second query that reruns on every widget change will make the app feel broken even to forgiving users.

The pattern that works: separate scheduled refresh from interactive render. A background job (a cron container, Airflow task, or dbt run) writes a materialised view or parquet file to an intermediate store (S3, GCS, or a staging schema). The Streamlit app reads from the intermediate store with a short-TTL cache. The dashboard is always fast; the freshness is determined by the refresh schedule, not the user’s patience.

For dbt users: expose dbt models via a read-only analytics schema and connect Streamlit to that—not to raw tables. This gives you a stable schema contract between the dashboard and the data layer, so dbt refactors do not silently break the app.

4. Auth and access control

Streamlit Community Cloud offers Google-based auth. Self-hosted Streamlit has no auth built in. The two production patterns:

Reverse proxy auth. Put an nginx or Caddy proxy with OAuth2 Proxy or Authelia in front of the Streamlit process. The proxy handles the OIDC flow and forwards authenticated requests. Streamlit sees the X-Forwarded-User header and uses it for role-based logic. This is simple, works with any OIDC provider, and keeps auth outside the application code.

Application-level auth via st.experimental_user or a third-party package. streamlit-authenticator and similar libraries implement login forms inside Streamlit. This is appropriate for quick internal tools that do not have an existing OIDC provider. For anything tied to a company SSO, the reverse proxy approach is cleaner and easier to audit.

Role-based page routing: Streamlit’s multipage app feature routes by file. Combine it with a session state variable set by the auth layer (st.session_state.user_role) and guard each page’s content with an early-exit check. It is manual but transparent.

5. Deployment: the Streamlit process model

Streamlit runs as a single Python process with multiple threads—one per active session. This means:

  • Horizontal scaling requires sticky sessions. A load balancer that sends the same user to different Streamlit instances breaks session state. Use session affinity (cookie-based sticky sessions in nginx or your cloud load balancer) unless you move session state to Redis.
  • Memory is not isolated between sessions. A memory leak in one session’s code path affects all sessions. Profile memory with tracemalloc or memory-profiler before deploying large apps.
  • Restart is zero-downtime only with a process manager. Use Gunicorn or a container restart policy with a health check endpoint. Streamlit’s built-in server does not handle graceful shutdown of active sessions.

For containerised deployments: pin streamlit, pandas, and your connector libraries in requirements.txt with exact versions—not ranges. A pip install streamlit two weeks later may pull a version that changes widget behaviour.

Take a Streamlit app to production

We review caching strategy, session isolation, auth, and deployment before anything breaks at scale.

Contact form

Send us a short message and we usually reply within one business day.

Christian Wörle

Your contact person

Christian Wörle

Technical Lead

contact@devolute.org