Best practices for building Streamlit apps
Streamlit gives you many ways to build the same app. This guide collects recommended patterns that keep your app consistent, accessible, fast, and easy to maintain. Each section links to more detailed guides. Treat these as defaults: if your app clearly needs something different, do what works for your users.
Summary
- Prefer native Streamlit commands over custom HTML, CSS, and third-party components.
- Customize your app's appearance with theming instead of CSS.
- Use sentence casing, Material Symbols icons, and descriptive labels and
alttext. - Use
widthinstead of the deprecateduse_container_width, and prefer horizontal containers for rows of elements. - Pick selection widgets by the number of options.
- Use
st.navigationandst.Pagefor multipage apps, and move shared logic into modules. - Initialize Session State in one place, and give widgets stable keys.
- Cache expensive work, and don't loop your script for live updates.
- Keep secrets in
st.secrets, and use parameterized queries. - Type-check your app.
Prefer native features
Native Streamlit commands follow your app's theme, support light and dark mode, are accessible, and keep working when you upgrade Streamlit. Use custom HTML only when no native command provides the UI you need.
- Use
st.iframeto embed a URL or HTML in an iframe.st.components.v1.htmlandst.components.v1.iframeare deprecated. - Use
st.htmlfor static HTML or CSS that renders directly in your app. JavaScript is ignored unless you setunsafe_allow_javascript=True. Never enable it for untrusted content. - To build a new interactive element, use Components v2.
Many popular community components now have native equivalents. Community components aren't maintained by Streamlit and can break when you upgrade Streamlit, so prefer the native command when it covers your needs:
| Instead of | Use |
|---|---|
streamlit-autorefresh | @st.fragment(run_every=...) on the section that should refresh |
streamlit-aggrid | st.dataframe with column_config, or st.data_editor for editing |
streamlit-folium | st.map for points, or st.pydeck_chart for layered maps |
streamlit-option-menu | st.navigation to switch pages, or st.segmented_control to switch views on a page |
streamlit-echarts | st.echarts_chart |
streamlit-keyup | st.text_input(..., live=True) |
| Cookie managers for sign-in | st.login and st.user |
| Hand-built pagination | st.pagination |
A community component is still the right choice for behavior Streamlit doesn't provide, like pivot tables, per-column filter menus, or JavaScript callbacks in ECharts options. Before adopting one, check that it's actively maintained and compatible with your Streamlit version.
Style your app with theming
Set colors, fonts, borders, and spacing in your theme configuration instead of injecting CSS. Theming applies consistently to every element, while CSS selectors depend on Streamlit's internal HTML structure, which can change between versions.
# Avoid: CSS that targets internal class names
st.html("<style>.stButton button { background: #0969da; }</style>")
# Prefer: theme options in .streamlit/config.toml
[theme]
primaryColor = "#0969da"
baseRadius = "0.5rem"
showWidgetBorder = true
If you need CSS for a single widget, give it a key and target its st-key-<key> class instead of internal class names or data-testid attributes. For an element without a key parameter, like st.markdown or st.metric, wrap it in st.container(key=...) and target the container. This is more robust, but the exact HTML structure still isn't guaranteed to stay the same between versions.
Write clear labels and text
Small details in your copy make your app feel polished and accessible.
-
Use sentence casing for titles, headers, labels, and buttons. For example, use "Upload your data" instead of "Upload Your Data."
-
Prefer Material Symbols icons over emojis for buttons, navigation, and labels. Use the
iconparameter or the:material/icon_name:Markdown syntax. Browse icons at Google Fonts.Pythonst.button("Refresh data", icon=":material/refresh:") -
Never use an empty label. Screen readers use labels to describe widgets. To hide a label, set
label_visibility="collapsed".Pythonquery = st.text_input("Search", label_visibility="collapsed", placeholder="Search") -
Use
alton visual elements. Images, charts, maps, media, iframes, PDFs, and dataframes accept a keyword-onlyaltparameter that names the element for assistive technologies. Keep it short and describe what the user should take away.Pythonst.line_chart(df, x="month", y="revenue", alt="Monthly revenue, up 8% since January") -
Use the lightest element that fits. Use
st.captionfor metadata like timestamps, and reservest.info,st.warning,st.error, andst.successfor messages that need attention. Usest.toastfor confirmations that should disappear on their own. To give an alert a headline, use itstitleparameter instead of bolding the first line. -
Use dividers sparingly. Streamlit's default spacing usually separates sections well. Use
st.spacewhen you need a little more room.
Lay out elements
-
Use
widthinstead ofuse_container_width. Theuse_container_widthparameter is deprecated. Usewidth="stretch"orwidth="content"instead. Dataframes and most charts stretch to fill their container by default. Buttons fit their content by default.Pythonst.dataframe(df) # Stretches by default st.download_button("Download CSV", csv, "orders.csv", width="stretch") -
Prefer horizontal containers for rows of elements.
st.container(horizontal=True)keeps each element at its own width (buttons fit their content) and wraps elements on narrow screens. Usest.columnswhen you need fixed proportions, and usest.gridfor tiles that should line up in columns across rows.Pythonwith st.container(horizontal=True, horizontal_alignment="right"): st.button("Cancel") st.button("Save", type="primary") -
Align elements side by side. When a row mixes labeled inputs with buttons, checkboxes, or toggles, set
vertical_alignment="bottom"so they line up with the input field. For a row of cards, give the cards consistent content (for example, every metric has adeltaor none does), or setheight="stretch"so they share a height.Pythonwith st.container(horizontal=True, vertical_alignment="bottom"): st.text_input("Customer") st.toggle("Active only") st.button("Search") -
Use borders for grouping. Use
border=Trueonst.containerorst.metricto draw a card. Onst.columnsandst.grid,border=Truedraws a border around each cell. Forms have a border by default. -
Use a wide layout for dashboards. Call
st.set_page_config(layout="wide")when your app shows a lot of data side by side.
For more information, see Using layouts and containers.
Choose the right widget
For selection widgets, consider how many options there are and whether users should see them all at once:
| Options | Single selection | Multiple selections |
|---|---|---|
| About two to five | st.segmented_control | st.pills with selection_mode="multi" |
| More than five | st.selectbox | st.multiselect |
- Prefer
st.segmented_controloverst.radio(..., horizontal=True). A verticalst.radiois still a good choice for longer labels. - Use
st.togglefor settings that change the app immediately, andst.checkboxfor choices in a form, like agreeing to terms. - Use
st.feedbackfor thumbs, faces, or star ratings. A rating can be0, so checkrating is not Noneinstead ofif rating. - Use
st.menu_buttonfor a button that opens a short list of actions, instead of a popover full of buttons. - To trigger a one-time action from a dataframe row, like opening a dialog with its details, use
st.column_config.ButtonColumn. Use dataframe selections when the result should persist while the row is selected.
Structure your app
-
Name your entrypoint file
streamlit_app.py. -
For multipage apps, use
st.navigationandst.Pageinstead of the legacypages/directory. They give you more control over your navigation. Put your page files in a folder likeapp_pages/, and give each page a Material Symbols icon.Pythonpage = st.navigation( [ st.Page("app_pages/home.py", title="Home", icon=":material/home:"), st.Page("app_pages/sales.py", title="Sales", icon=":material/analytics:"), ] ) page.run() -
Move shared logic, like data loading and helpers, into modules that your pages import.
Manage state and keys
-
Initialize Session State in one place, near the top of your script, so it's easy to see what state your app uses.
Pythonst.session_state.setdefault("filters", {}) st.session_state.setdefault("selected_account", None) -
Don't use module-level mutable variables for per-user data. Imported modules are shared by every user of your app. Use Session State instead.
-
Give widgets a
keywhen you create widgets in a loop, when their parameters change, or when you need to read or set their value from code. Without a key, changing a widget's label or options can reset its value. -
Keep keys unique. In a loop, derive each key from a stable ID, like
key=f"done_{item.id}". In a multipage app, keys must be unique across the entrypoint and the current page. -
Sync widgets to the URL with
bind="query-params"instead of reading and writingst.query_paramsby hand. Don't bind sensitive values because URLs appear in browser history and shared links.Pythonsort = st.selectbox("Sort", ["Newest", "Oldest"], key="sort", bind="query-params")
For more information, see Session State and Widget behavior.
Keep your app fast
- Cache data loading and expensive computation with
st.cache_data, and shared resources like clients and models withst.cache_resource. - Render titles, filters, and other fast UI before slow work, and use
st.skeletonto reserve space for slow elements. - Use
@st.fragment(run_every=...)for live updates instead ofwhile Trueloops ortime.sleep()withst.rerun(). - Use forms to batch inputs that feed expensive work.
- Don't run expensive code inside hidden tabs or collapsed expanders.
For explanations and examples, see Optimizing app performance. For guidance on charts, see Choosing and using charts.
Keep your app secure
-
Store credentials in
st.secrets, and add.streamlit/secrets.tomlto your.gitignorefile. -
Use parameterized queries for user-provided values instead of formatting them into SQL strings.
Pythondf = conn.query( "SELECT * FROM orders WHERE region = :region", params={"region": region}, ) -
Remove sensitive columns before you display data. Hiding a column with
column_configonly hides it visually; the data is still sent to the browser.Pythonst.dataframe(df.drop(columns=["api_token"])) -
Treat widget limits like
max_charsandmax_valueas a convenience, not a security boundary. Validate input in Python before you use it for anything sensitive.
For more information, see Security reminders.
Type-check your app
Streamlit's public API has type annotations. Run a type checker like ty or mypy on your app to catch mistakes, like misspelled parameters, before you run it.
st.slider("Count", min_value=0, maximum=10) # A type checker flags this call
To annotate values that Streamlit returns, like chart selection events or data editor state, import types from streamlit.typing.
Still have questions?
Our forums are full of helpful information and Streamlit experts.