Streamlit

The idea of Streamlit is to simplify application development by rerunning the entire application script whenever any user input changes. This strategy leads to a great initial user experience, but quickly becomes constricting as your application grows in scope.

Shiny and Streamlit differ in a few key ways:

  1. Shiny’s reactive execution means that elements are minimally re-rendered.
  2. You can build large Shiny applications without manually managing application state or caching data.
  3. Shiny allows you to easily customize the look and feel of your application.

Shiny is designed to support your application’s growth without extensive rewriting; the patterns you learn when developing a simple app are robust enough to handle a complicated one.

Streamlit example

Consider this basic Streamlit application which filters a dataset and draws two plots. The nice thing about this application is that it’s very similar to a non-interactive script. This makes getting started very easy because all you need to do to turn this script into an application is to add some Streamlit function calls to your variables and outputs. At the beginning, Streamlit doesn’t demand that you change your code to fit into a particular structure.

The way Streamlit achieves this is by rerunning your script from start to finish every time the user takes an action. While this works okay for small applications it is inefficient, and becomes intractable for larger more complicated ones. In this case clicking the Add Smoother button will cause the entire app to reload, even though the button is only used by one plot.

import streamlit as st
import pandas as pd
from plotnine import ggplot, geom_density, aes, theme_light, geom_point, stat_smooth
from pathlib import Path

infile = Path(__file__).parent / "penguins.csv"
df = pd.read_csv(infile)


def dist_plot(df):
    plot = (
        ggplot(df, aes(x="Body Mass (g)", fill="Species"))
        + geom_density(alpha=0.2)
        + theme_light()
    )
    return plot.draw()


def scatter_plot(df, smoother):
    plot = (
        ggplot(
            df,
            aes(
                x="Bill Length (mm)",
                y="Bill Depth (mm)",
                color="Species",
                group="Species",
            ),
        )
        + geom_point()
        + theme_light()
    )

    if smoother:
        plot = plot + stat_smooth()

    return plot.draw()


with st.sidebar:
    mass = st.slider("Mass", 2000, 8000, 6000)
    smoother = st.checkbox("Add Smoother")

filt_df = df.loc[df["Body Mass (g)"] < mass]

st.pyplot(scatter_plot(filt_df, smoother))
st.pyplot(dist_plot(filt_df))

Shiny translation

Shiny express apps look very similar to Streamlit apps, but run much more efficiently. Unlike Streamlit, Shiny does not rerender the application every time an input is changed, but instead keeps track of the relationships between components and minimally rerenders the components which need to be updated. The framework does this automatically when the application is run, and so you don’t need to manually define the execution method for your app.

#| standalone: true
#| components: [editor, viewer]
#| layout: horizontal
#| viewerHeight: 800
from pathlib import Path

import pandas as pd
from palmerpenguins import load_penguins
from plots import dist_plot, scatter_plot
from shiny import reactive, render, ui
from shiny.express import input, ui

df = load_penguins()

with ui.sidebar():
    ui.input_slider("mass", "Mass", 2000, 8000, 6000)
    ui.input_checkbox("smoother", "Add Smoother")


@reactive.calc
def filtered_data():
    filt_df = df.copy()
    filt_df = filt_df.loc[df["body_mass_g"] < input.mass()]
    return filt_df


with ui.card():

    @render.plot
    def scatter():
        return scatter_plot(filtered_data(), input.smoother())


with ui.card():

    @render.plot
    def mass_distribution():
        return dist_plot(filtered_data())

## file: plots.py
from plotnine import aes, geom_density, geom_point, ggplot, stat_smooth, theme_light


def dist_plot(df):
    plot = (
        ggplot(df, aes(x="body_mass_g", fill="species"))
        + geom_density(alpha=0.2)
        + theme_light()
    )
    return plot


def scatter_plot(df, smoother):
    plot = (
        ggplot(
            df,
            aes(
                x="bill_length_mm",
                y="bill_depth_mm",
                color="species",
                group="species",
            ),
        )
        + geom_point()
        + theme_light()
    )

    if smoother:
        plot = plot + stat_smooth()

    return plot

## file: requirements.txt
shiny
palmerpenguins
plotnine
pandas
#| standalone: true
#| components: [editor, viewer]
#| layout: horizontal
#| viewerHeight: 800
from pathlib import Path

import pandas as pd
from palmerpenguins import load_penguins
from plots import dist_plot, scatter_plot
from shiny import App, reactive, render, ui

app_ui = ui.page_sidebar(
    ui.sidebar(
        ui.input_slider("mass", "Mass", 2000, 8000, 6000),
        ui.input_checkbox("smoother", "Add Smoother"),
    ),
    ui.card(ui.output_plot(id="scatter")),
    ui.card(ui.output_plot(id="mass_distribution")),
)


def server(input, output, session):
    df = load_penguins()
    print(df)

    @reactive.calc
    def filtered_data():
        filt_df = df.copy()
        filt_df = filt_df.loc[df["body_mass_g"] < input.mass()]
        return filt_df

    @render.plot
    def mass_distribution():
        return dist_plot(filtered_data())

    @render.plot
    def scatter():
        return scatter_plot(filtered_data(), input.smoother())


app = App(app_ui, server)

## file: plots.py
from plotnine import aes, geom_density, geom_point, ggplot, stat_smooth, theme_light


def dist_plot(df):
    plot = (
        ggplot(df, aes(x="body_mass_g", fill="species"))
        + geom_density(alpha=0.2)
        + theme_light()
    )
    return plot


def scatter_plot(df, smoother):
    plot = (
        ggplot(
            df,
            aes(
                x="bill_length_mm",
                y="bill_depth_mm",
                color="species",
                group="species",
            ),
        )
        + geom_point()
        + theme_light()
    )

    if smoother:
        plot = plot + stat_smooth()

    return plot

## file: requirements.txt
shiny
palmerpenguins
plotnine
pandas

The main difference between Streamlit and Shiny is code organization. Since Streamlit runs everything from top to bottom it doesn’t particularly matter how your code is organized. In order to benefit from Shiny’s execution model, you need to organize your code into decorated functions.

For example, take this part of the application code:

@reactive.calc
def filtered_data():
    filt_df = df.copy()
    filt_df = filt_df.loc[df["body_mass_g"] < input.mass()]
    return filt_df

@render.plot
def mass_distribution():
    return dist_plot(filtered_data())

@render.plot
def scatter():
    return scatter_plot(filtered_data(), input.smoother())

These functions define the three main nodes of the application, as well as the relationships between them. The @render.plot and @reactive.calc decorators identify the functions as reactive functions which need to re-execute in response to upstream changes, and the filtered_data() and input.* calls define the relationships between these components. The decorators allow Shiny to construct a computation graph of the application as it runs, and only rerender an element when one of its upstream dependencies changes.

flowchart LR
  S[input.mass] --> F[Filtered Data]
  F --> H((Distribution))
  F --> SC((Scatterplot))
  C[input.smoother] --> SC

Extending the application

Organizing your app this way means that you can extend the application without rewriting it. For example, let’s add a button which resets the slider. In Shiny you can do this by adding a @reactive.effect function which calls the ui.update_slider() function. This adds a node to the computation graph and everything works as you’d expect it to. Importantly, we can extend the application without changing how we think about the overall application.

flowchart LR
  S[input.mass] --> F[Filtered Data]
  F --> H((Distribution))
  F --> SC((Scatterplot))
  C[input.smoother] --> SC
  R{Reset} -.-> S

#| standalone: true
#| components: [editor, viewer]
#| layout: horizontal
#| viewerHeight: 800
from pathlib import Path

import pandas as pd
from palmerpenguins import load_penguins
from plots import dist_plot, scatter_plot
from shiny import reactive, render
from shiny.express import input, ui

df = load_penguins()

with ui.sidebar():
    ui.input_slider("mass", "Mass", 2000, 8000, 6000)
    ui.input_checkbox("smoother", "Add Smoother")
    ui.input_action_button("reset", "Reset Slider")


@reactive.effect
@reactive.event(input.reset)
def _():
    ui.update_slider("mass", value=6000)


@reactive.calc
def filtered_data():
    filt_df = df.copy()
    filt_df = filt_df.loc[df["body_mass_g"] < input.mass()]
    return filt_df


with ui.card():

    @render.plot
    def scatter():
        return scatter_plot(filtered_data(), input.smoother())


with ui.card():

    @render.plot
    def mass_distribution():
        return dist_plot(filtered_data())

## file: plots.py
from plotnine import aes, geom_density, geom_point, ggplot, stat_smooth, theme_light


def dist_plot(df):
    plot = (
        ggplot(df, aes(x="body_mass_g", fill="species"))
        + geom_density(alpha=0.2)
        + theme_light()
    )
    return plot


def scatter_plot(df, smoother):
    plot = (
        ggplot(
            df,
            aes(
                x="bill_length_mm",
                y="bill_depth_mm",
                color="species",
                group="species",
            ),
        )
        + geom_point()
        + theme_light()
    )

    if smoother:
        plot = plot + stat_smooth()

    return plot

## file: requirements.txt
shiny
palmerpenguins
plotnine
pandas
#| standalone: true
#| components: [editor, viewer]
#| layout: horizontal
#| viewerHeight: 800
from pathlib import Path

import pandas as pd
from palmerpenguins import load_penguins
from plots import dist_plot, scatter_plot
from shiny import App, reactive, render, ui

app_ui = ui.page_sidebar(
    ui.sidebar(
        ui.input_slider("mass", "Mass", 2000, 8000, 6000),
        ui.input_checkbox("smoother", "Add Smoother"),
        ui.input_action_button("reset", "Reset Slider"),
    ),
    ui.card(ui.output_plot(id="scatter")),
    ui.card(ui.output_plot(id="mass_distribution")),
)


def server(input, output, session):
    df = load_penguins()

    @reactive.calc
    def filtered_data():
        filt_df = df.copy()
        filt_df = filt_df.loc[df["body_mass_g"] < input.mass()]
        return filt_df

    @output
    @render.plot
    def mass_distribution():
        return dist_plot(filtered_data())

    @output
    @render.plot
    def scatter():
        return scatter_plot(filtered_data(), input.smoother())


app = App(app_ui, server)

## file: plots.py
from plotnine import aes, geom_density, geom_point, ggplot, stat_smooth, theme_light


def dist_plot(df):
    plot = (
        ggplot(df, aes(x="body_mass_g", fill="species"))
        + geom_density(alpha=0.2)
        + theme_light()
    )
    return plot


def scatter_plot(df, smoother):
    plot = (
        ggplot(
            df,
            aes(
                x="bill_length_mm",
                y="bill_depth_mm",
                color="species",
                group="species",
            ),
        )
        + geom_point()
        + theme_light()
    )

    if smoother:
        plot = plot + stat_smooth()

    return plot

## file: requirements.txt
shiny
palmerpenguins
plotnine
pandas

Streamlit requires rewriting

Streamlit is optimized for very simple applications, but the cost of that is that Streamlit applications can be quite challenging to extend. For example, to add a reset button to Streamlit you might expect that something like this would work. After all, if your script runs from top-to-bottom whenever a button is pressed, shouldn’t you be able to redefine a slider using an if statement?

import streamlit as st

x = st.slider("x", 0, 10, 5)
btn = st.button("Reset")
if btn:
    x = st.slider("x", 0, 10, 5)

Unfortunately, this doesn’t work because Streamlit maintains hidden application state, and resetting the slider value causes a name conflict. In order to get this to work you need to first initialize a state variable slider which matches the key of the slider input widget, then you need to define a callback function and pass that as an argument to the button function. Streamlit then uses the slider key to look for a variable with that same key session state. This variable defines the value of the slider.

The difficulty here is that in order to get the app to work you need to change your mental model of how the application runs. Instead of thinking about your app as a simple Python script which reruns when anything changes, you need to start thinking about manually manipulating the state variables which persist across runs. The limitations of the simple rerun-everything model will require you to add more and more workarounds like this as your application grows in complexity.

import streamlit as st
import pandas as pd
from plotnine import ggplot, geom_density, aes, theme_light, geom_point, stat_smooth
from pathlib import Path

infile = Path(__file__).parent / "penguins.csv"
df = pd.read_csv(infile)


def dist_plot(df):
    plot = (
        ggplot(df, aes(x="Body Mass (g)", fill="Species"))
        + geom_density(alpha=0.2)
        + theme_light()
    )
    return plot.draw()


def scatter_plot(df, smoother):
    plot = (
        ggplot(
            df,
            aes(
                x="Bill Length (mm)",
                y="Bill Depth (mm)",
                color="Species",
                group="Species",
            ),
        )
        + geom_point()
        + theme_light()
    )

    if smoother:
        plot = plot + stat_smooth()

    return plot.draw()

# You need to check for the variable in session state to avoid an error
if "slider" not in st.session_state:
    st.session_state["slider"] = 6000

def reset_value():
    st.session_state["slider"] = 6000


with st.sidebar:
    mass = st.slider(
        label="Mass",
        min_value=2000,
        max_value=8000,
        key="slider", # The `key` imports the number which is stored in `session_state`
    )
    smoother = st.checkbox("Add Smoother")
    reset = st.button("Reset Slider", on_click=reset_value)

filt_df = df.loc[df["Body Mass (g)"] < mass]

st.pyplot(scatter_plot(filt_df, smoother))
st.pyplot(dist_plot(filt_df))

Customizing UI

Shiny embraces UI as HTML, and as a result it’s relatively easy to implement bespoke UI customizations. For example, lets change the color of one button without changing the colors of any other buttons in our app. Since Shiny allows you to add HTML attributes like class/style, and provides a CSS framework (Bootstrap), we can make primary button by just adding an appropriate class attribute.

#| standalone: true
#| components: [editor, viewer]
#| layout: vertical
#| viewerHeight: 100
from shiny.express import ui

ui.input_action_button("default", "Default Button")
ui.input_action_button("primary", "Primary Button", class_="btn-outline-primary")

You might not need to customize the CSS of your app that often, but it’s important to have the option if your application calls for it. For example, suppose your company wants to publish your application publicly on their website, but in order to do that you need to make sure that it matches their style guide. You can do that with Shiny because it supports the same styling patterns that your company is probably already using.

Streamlit

This task is almost impossible in Streamlit, and requires a JavaScript workaround.

import streamlit as st
import streamlit.components.v1 as components

st.button("red", "Red Button")
st.button("white", "White Button")


def ChangeButtonColour(widget_label, font_color, background_color="transparent"):
    htmlstr = f"""
        <script>
            var elements = window.parent.document.querySelectorAll('button');
            for (var i = 0; i < elements.length; ++i) {{
                if (elements[i].innerText == '{widget_label}') {{
                    elements[i].style.color ='{font_color}';
                    elements[i].style.background = '{background_color}'
                }}
            }}
        </script>
        """
    components.html(f"{htmlstr}", height=0, width=0)


ChangeButtonColour("red", "white", "red")

Despite its complexity, this is the best way to change the style of an individual element in Streamlit. How this pattern works is:

  • Return an empty html component with a script tag
  • Use that script to break out of the iframe and access the parent document
  • Search through the parent elements for those which matches a string
  • Change the style of those elements

This pattern is fairly tricky to understand, and can lead to some unexpected bugs. For instance changes to page structure or button names can cause the styling to behave unpredictably.

Streamlit was designed around simple applications which didn’t require customized styling, so it’s no surprise that this type of styling is difficult. It is, however, an example of how the up-front simplicity of Streamlit has a significant cost when you go outside the boundaries of that simplicity. The fact that this is a fairly common workaround is an indication that Streamlit users commonly exceed those boundaries.

Privacy and security

Streamlit collects user information on everyone who visits a running Streamlit app unless you opt-out. The data is sent to a American server owned by Snowflake so that the company can analyze user behavior. This can cause legal and security problems because your application may be subject to data governance policies which forbid this type of data collection. For example, if your users do not explicitly provide consent to transfer data to a US company, sending data to Snowflake might be a GDPR violation. In order to prevent data collection you need to set gatherUsageStats = false in your Streamlit config file, which is an easy thing to forget to include in a given Streamlit deployment.

Shiny does not collect or report user data of any kind, and it never will. We do not believe that open-source tools should collect user data without explicit consent.

Conclusion

Shiny allows you to build much more performant and extensible applications than Streamlit. The patterns that you use to build a simple Shiny application are the same ones that you use to build a complex one, and you never need to change your mental model of how the application works. This design will let your application grow along with the scope of your problem, and you can have confidence that the framework has the tools that you need to handle almost any requirement.