Last Updated: June 01, 2026
- Streamlit Quick Example
- What Is Streamlit and Why Use It?
- Core Widgets: Inputs, Buttons, Sliders
- Layout: Columns, Tabs, and Sidebars
- Displaying DataFrames, Tables, and Charts
- Session State: Persisting Data Across Reruns
- Caching: Fast Reruns with @st.cache_data
- Forms: Batch Multiple Inputs Before Rerunning
- Deploying a Streamlit App
- Common Pitfalls
- FAQ
- Wrapping Up
- Related Python Tutorials
Intermediate
You’ve built a useful Python script — maybe a data cleaner, a forecasting model, or an internal report. Now your team wants to use it without typing a single command. Traditionally that means picking up Flask or Django, learning a templating language, writing HTML and CSS, and deploying a real web app. Streamlit is the antidote: a single Python file becomes a polished web UI with widgets, charts, and state — no frontend, no templates, no JavaScript.
This guide covers the practical parts of Streamlit you actually need: laying out widgets, handling user input, displaying dataframes and charts, controlling reactivity with session state and caching, and shipping the app somewhere your team can reach it. By the end you’ll know how to turn any analysis script into an interactive tool in a couple of hundred lines.
Python developer and educator with 15+ years building production systems across data engineering, web APIs, and AI tooling. Founder of Python How To Program — 270+ in-depth tutorials covering the modern Python stack.
View all tutorials by Pubs →Streamlit Quick Example
Part of the Python Data Stack Hub. See the full hub for related Python tutorials.
Here’s a working Streamlit app in 15 lines. Install with pip install streamlit, save the file, and run with streamlit run app.py:
# File: app.py
import streamlit as st
import pandas as pd
st.title("Sales Explorer")
uploaded = st.file_uploader("Upload a CSV", type="csv")
if uploaded:
df = pd.read_csv(uploaded)
region = st.selectbox("Filter by region", ["All"] + sorted(df["region"].unique()))
if region != "All":
df = df[df["region"] == region]
st.metric("Total sales", f"${df['amount'].sum():,.0f}")
st.dataframe(df)
st.bar_chart(df.groupby("product")["amount"].sum())
This single file gives you: a title, file upload, a dropdown filter, a metric tile, a sortable dataframe view, and a bar chart — all auto-styled and responsive. Every interaction (uploading a file, picking a region) reruns the script top-to-bottom with the new inputs. That rerun model is the core mental model of Streamlit.
What Is Streamlit and Why Use It?
Streamlit turns any Python script into a web app by intercepting calls like st.title(), st.button(), and st.dataframe() and rendering them as HTML on a tiny built-in server. The framework runs your script from top to bottom every time a widget value changes, so you write code as if it were a synchronous batch job — no callbacks, no event handlers, no state machines.
It’s a fit when you want to:
- Wrap a notebook into a shareable tool that non-developers can use.
- Build an internal dashboard backed by SQL queries or model inference.
- Demo a machine-learning model with a UI for tweaking inputs.
- Replace an Excel spreadsheet that your team passes around via email.
It’s not a fit when you need:
- Multi-user state (auth, per-user data, real-time collaboration).
- SEO-friendly public pages (Streamlit apps are client-rendered and don’t index well).
- Fine-grained UI control (custom CSS animations, complex form validation, drag-and-drop).
For internal tools and quick analytical UIs, Streamlit is unmatched in time-to-prototype. For consumer-facing apps, reach for FastAPI + a real frontend.
Core Widgets: Inputs, Buttons, Sliders
Every widget returns its current value. You wire up logic by branching on those values — no event handlers required:
# File: widgets_demo.py
import streamlit as st
st.header("Widget Sampler")
# Text inputs
name = st.text_input("Your name", value="Pubs")
notes = st.text_area("Notes", height=120)
# Numeric input + slider
age = st.number_input("Age", min_value=0, max_value=120, value=30)
budget = st.slider("Budget", 0, 10000, value=2500, step=100)
# Selection
plan = st.selectbox("Plan", ["Free", "Pro", "Team", "Enterprise"])
features = st.multiselect("Features", ["API", "Webhooks", "SSO", "SLA"])
# Boolean inputs
agree = st.checkbox("I agree to the terms")
priority = st.radio("Priority", ["Low", "Medium", "High"], horizontal=True)
# Date + time
event_date = st.date_input("Event date")
# Action buttons
if st.button("Submit"):
if not agree:
st.error("You must agree to the terms")
else:
st.success(f"Submitted: {name}, {plan}, ${budget}, {features}")
Notice how the entire form is just a sequence of variable assignments and an if st.button(): block. There’s no onClick, no form submission handler — Streamlit handles all of that. Each widget needs a unique label (used as both the visible label and the internal identifier).
Layout: Columns, Tabs, and Sidebars
The default layout is a single vertical stream of content. For anything more interesting, use columns, tabs, and the sidebar:
# File: layout_demo.py
import streamlit as st
st.title("Inventory Dashboard")
# Sidebar — controls
with st.sidebar:
st.header("Filters")
category = st.selectbox("Category", ["Beverages", "Snacks", "Frozen"])
show_low_stock = st.checkbox("Only low stock")
# Main area — two columns of metrics
col1, col2, col3 = st.columns(3)
col1.metric("Total SKUs", 1248, "+12 vs last week")
col2.metric("Out of Stock", 47, "-3", delta_color="inverse")
col3.metric("Avg Margin", "23.4%", "+1.2pp")
# Tabbed views below
tab1, tab2, tab3 = st.tabs(["Sales", "Inventory", "Suppliers"])
with tab1:
st.subheader("Sales Trend")
st.line_chart({"week_1": 4200, "week_2": 4800, "week_3": 5100, "week_4": 4900})
with tab2:
st.subheader("Stock Levels")
st.write("...")
with tab3:
st.subheader("Supplier Performance")
st.write("...")
The with blocks scope subsequent calls to that container — anything inside with tab1: shows up only on the Sales tab. st.columns(3) returns three column objects; calling col1.metric(...) places the metric in that column. The sidebar is global — anything inside with st.sidebar: appears in the persistent left panel regardless of which main-area tab is active.
Displaying DataFrames, Tables, and Charts
Streamlit recognizes pandas DataFrames and renders them as sortable, scrollable, search-friendly grids. Same with charts — pass a DataFrame to st.line_chart, st.bar_chart, or st.area_chart and you get an interactive plot with zero configuration:
# File: dataframes_demo.py
import streamlit as st
import pandas as pd
import numpy as np
# Generate sample data
np.random.seed(42)
df = pd.DataFrame({
"date": pd.date_range("2026-01-01", periods=90),
"revenue": np.random.normal(5000, 800, 90).cumsum(),
"users": np.random.poisson(120, 90),
"region": np.random.choice(["NA", "EU", "APAC"], 90),
})
st.header("Q1 Performance")
# Interactive dataframe — sortable, searchable, column-resizable
st.dataframe(df, use_container_width=True, hide_index=True)
# Static table — no interactivity but renders faster
st.subheader("Top 5 Days")
st.table(df.nlargest(5, "revenue")[["date", "revenue", "users"]])
# Charts auto-detect numeric columns
st.subheader("Revenue Over Time")
st.line_chart(df.set_index("date")["revenue"])
st.subheader("Users by Region")
st.bar_chart(df.groupby("region")["users"].sum())
For richer plots, drop in matplotlib, plotly, or altair — Streamlit accepts all three via st.pyplot(), st.plotly_chart(), and st.altair_chart(). Plotly and altair are interactive (zoom, pan, hover tooltips) and integrate seamlessly with the Streamlit layout system.
Session State: Persisting Data Across Reruns
Streamlit reruns your entire script on every interaction. That’s clean for stateless UIs but tricky for anything that needs to persist — a shopping cart, an undo history, an authentication token. The solution is st.session_state, a dict-like object that survives between reruns:
# File: counter.py
import streamlit as st
# Initialize state (only runs once per session)
if "count" not in st.session_state:
st.session_state.count = 0
st.session_state.history = []
st.title("Counter with History")
col1, col2, col3 = st.columns(3)
if col1.button("➖ Decrement"):
st.session_state.count -= 1
st.session_state.history.append(("dec", st.session_state.count))
if col2.button("➕ Increment"):
st.session_state.count += 1
st.session_state.history.append(("inc", st.session_state.count))
if col3.button("Reset"):
st.session_state.count = 0
st.session_state.history.append(("reset", 0))
st.metric("Current Value", st.session_state.count)
st.write("Recent actions:")
for action, value in reversed(st.session_state.history[-10:]):
st.text(f" {action} -> {value}")
The if "count" not in st.session_state: block is the standard idiom for initialization — it runs once when the session starts, never again. After that, treat st.session_state like a regular dict that magically persists.
Caching: Fast Reruns with @st.cache_data
Because the entire script reruns on every interaction, expensive operations (file loads, SQL queries, ML inference) would re-execute repeatedly without caching. Streamlit’s @st.cache_data decorator memoizes function results based on arguments:
# File: caching_demo.py
import streamlit as st
import pandas as pd
import time
@st.cache_data
def load_sales_data(year: int) -> pd.DataFrame:
\"\"\"Slow data load — only runs when year changes.\"\"\"
time.sleep(2) # simulating slow database query
return pd.read_parquet(f"sales_{year}.parquet")
@st.cache_data(ttl=300) # invalidate after 5 minutes
def fetch_live_prices(symbol: str) -> dict:
\"\"\"Refetch prices at most every 5 minutes.\"\"\"
import requests
r = requests.get(f"https://api.example.com/quote/{symbol}", timeout=5)
return r.json()
st.title("Sales Dashboard")
year = st.selectbox("Year", [2024, 2025, 2026])
df = load_sales_data(year) # 2s the first time, instant after
prices = fetch_live_prices("AAPL") # cached for 5 minutes
st.dataframe(df)
st.metric("AAPL Price", f"${prices['price']:.2f}")
Use @st.cache_data for serializable return values (DataFrames, dicts, primitives). Use @st.cache_resource for non-serializable resources like database connections or loaded ML models — those should be shared across all sessions instead of cached per-input.
Forms: Batch Multiple Inputs Before Rerunning
By default, every widget change triggers a script rerun. That’s wasteful when you have a form with 8 fields — you don’t want to rerun 8 times as the user fills it in. Wrap the inputs in st.form() and Streamlit batches them until the submit button is clicked:
# File: forms_demo.py
import streamlit as st
st.title("New Order")
with st.form("order_form"):
customer = st.text_input("Customer name")
email = st.text_input("Email")
product = st.selectbox("Product", ["Widget", "Gadget", "Gizmo"])
quantity = st.number_input("Quantity", min_value=1, value=1)
notes = st.text_area("Order notes")
submitted = st.form_submit_button("Submit Order")
if submitted:
if not customer or not email:
st.error("Customer name and email are required")
else:
st.success(f"Order placed: {quantity} × {product} for {customer}")
Inside the form, widget values DON’T trigger reruns when changed. Only the form_submit_button click does. This is the right pattern for any input set where partial submissions would be confusing or expensive.
Deploying a Streamlit App
Three common deployment paths:
- Streamlit Community Cloud (free for public repos). Push your code to GitHub, link the repo at
share.streamlit.io, and you get a public URL in minutes. Best for demos and small internal tools. - Self-hosted on a small VPS. Run
streamlit run app.py --server.port 8501 --server.address 0.0.0.0behind nginx or Caddy with TLS. Add a basic auth header if it’s an internal tool. ~$5/month. - Containerize and deploy on Cloud Run / Fly.io / Render. Write a tiny Dockerfile (
FROM python:3.12-slim+pip install+CMD streamlit run app.py) and deploy as a single-container service. Scales automatically.
For multi-user authentication, put a reverse proxy in front (Cloudflare Access, Pomerium, oauth2-proxy). Streamlit itself has no built-in auth.
Common Pitfalls
- Forgetting that the script reruns top-to-bottom. Any code outside a function executes on every interaction. If you have a heavy import or model load at module level, it’s re-checked every time — use
@st.cache_resourceto load once. - Mutating cached return values.
@st.cache_datareturns a reference, not a copy. If you modify the returned DataFrame, you’ve corrupted the cache. Make a copy:df = load_sales_data(year).copy(). - Using widget keys for state. Each widget has an auto-generated key based on its label. If you have two
st.text_input("name")calls, Streamlit raises a DuplicateWidgetID error. Add an explicitkey=argument or change the labels. - Logging into
st.write.st.writeis for user-facing output. Use Python’sloggingmodule (with a real handler) for application logs — they appear in the terminal where you ranstreamlit run. - Long-running computations blocking the UI. Streamlit is single-threaded per session. A 30-second computation blocks all widgets until it finishes. Use
st.spinner()for a loading indicator and considerst.write_stream()for chunked output.
FAQ
Q: Is Streamlit production-ready?
A: For internal tools and dashboards, absolutely. Plenty of companies run Streamlit apps in production for analytics, ops dashboards, and ML model demos. For public-facing apps with thousands of concurrent users, you’d want to put it behind a reverse proxy with rate-limiting, and benchmark carefully — each session ties up a Python thread.
Q: Streamlit vs Dash vs Gradio?
A: Streamlit has the shortest learning curve and prettiest defaults. Dash gives you fine-grained control via a callback system (steeper learning curve, more like writing React). Gradio is purpose-built for ML model demos — auto-generates an interface from a function signature. Pick Streamlit for data dashboards, Gradio for ML demos, Dash when you need exact control.
Q: Can I customize the look beyond the default theme?
A: Yes. Set a .streamlit/config.toml with custom theme colors, fonts, and dark mode. For deeper changes, inject CSS via st.markdown(custom_css, unsafe_allow_html=True) — but this is unsupported and breaks across Streamlit upgrades.
Q: How do I handle file uploads bigger than the default limit?
A: Increase server.maxUploadSize in .streamlit/config.toml (default is 200 MB). For genuinely large files (multi-GB), use a presigned URL flow — upload directly to S3 from the browser and pass the S3 key to Streamlit.
Q: Does Streamlit support WebSockets / real-time updates?
A: Limited — Streamlit uses WebSockets internally, but you don’t get push-from-server primitives. For live data, use st.experimental_rerun() on a timer, or streamlit-autorefresh for declarative polling. For true real-time streams, consider a separate WebSocket server alongside Streamlit.
Wrapping Up
Streamlit’s superpower is collapsing the front-end stack into a single Python file. You write a script, you get a web app. For the 80% of internal tools and analytics dashboards where that’s all you need, it’s hard to beat. The 20% where you need fine-grained UI control or true multi-user state — that’s where Dash, FastAPI + a real frontend, or a full SaaS framework start to win.
The official Streamlit documentation has the complete API reference and a gallery of community apps you can study. For tutorials on related Python topics, see below.
Related Python Tutorials
Continue Learning Python
Tutorials you might also find useful:
- How To Use Python Plotly for Interactive Data Visualizations
- How To Work With JSON Data in Python Using the json Module
- Easy guide for data storage options in Python
- How To Use Python PyArrow for Columnar Data Processing
- How To Use Python cattrs for Structured Data Conversion
- How To Use Python glom for Nested Data Access