+1 (415) 997-4269

Streamlit in Snowflake: Shipping Governed Internal Data Apps Without Leaving the Account

Most analytics work ends in a dashboard, but a large slice of it does not fit a dashboard at all: a pricing override tool, a data-quality triage screen, a backfill launcher, a "which customers should we call this week" list with a write-back column. Those are applications. Historically they meant a Flask app on a VM, a service account, a credentials file, and a security review that took longer than the build.

Streamlit in Snowflake (SiS) removes almost all of that. The app runs inside your Snowflake account, executes as a Snowflake role, and queries data over the existing session — no egress, no copied extract, no separate secret to rotate. This tutorial covers when to reach for it, how to structure and deploy an app properly, and the governance and cost details that decide whether it survives contact with production.

When a Streamlit app is the right answer

Reach for SiS when at least two of these are true:

  • The user needs to input something, not just read (approve, override, tag, trigger).
  • The output is a workflow, not a chart: pick a run, inspect it, fix it, resubmit.
  • The data must not leave the account for policy reasons.
  • The audience is 5–500 internal users, not the whole company.
  • The logic is Python that would be awkward in a BI tool's expression language.

Stay with your BI tool when the deliverable is genuinely a governed dashboard with drilldowns, scheduled distribution, and hundreds of casual viewers. SiS is not a BI replacement; it is the thing you used to build in Flask.

And reach for Snowpark Container Services instead when you need a non-Streamlit frontend, a long-lived service, a custom container image, or a websocket API. SiS is deliberately narrow: one Python process per session, Streamlit only.

The execution model, which is the whole ballgame

A Streamlit app is a database object (CREATE STREAMLIT) with three properties that matter:

  1. Owner's rights vs caller's rights. By default a SiS app runs with the privileges of the role that owns it, not the viewer. That is convenient and dangerous. An owner's-rights app owned by a powerful role lets every viewer see everything that role can see, bypassing the row access policies you would have applied to them. Prefer caller's rights apps (EXECUTE AS CALLER) when the data is governed per-user — then masking policies and row access policies evaluate against the actual viewer, exactly as they would in a worksheet.
  2. A warehouse. The app has a query warehouse attached; every rerun of the script issues queries against it.
  3. A stage. The app's Python files live on a named stage (or in a Git repository stage), which is what makes CI/CD possible.

A minimal, deliberate setup:

USE ROLE sysadmin;

CREATE DATABASE IF NOT EXISTS apps;
CREATE SCHEMA   IF NOT EXISTS apps.ops;

-- Small, aggressively suspending warehouse: app sessions are bursty and idle-heavy.
CREATE WAREHOUSE IF NOT EXISTS wh_apps_xs
  WAREHOUSE_SIZE = 'XSMALL'
  AUTO_SUSPEND = 30
  AUTO_RESUME = TRUE
  INITIALLY_SUSPENDED = TRUE;

CREATE STAGE IF NOT EXISTS apps.ops.app_files
  DIRECTORY = (ENABLE = TRUE);

-- The role viewers hold.
CREATE ROLE IF NOT EXISTS app_dq_triage_user;
GRANT USAGE ON DATABASE apps            TO ROLE app_dq_triage_user;
GRANT USAGE ON SCHEMA   apps.ops        TO ROLE app_dq_triage_user;
GRANT USAGE ON WAREHOUSE wh_apps_xs     TO ROLE app_dq_triage_user;

Project layout

Keep the app in your normal repo next to the models it reads. A workable structure:

apps/dq_triage/
  snowflake.yml
  environment.yml        # conda packages from the Snowflake Anaconda channel
  streamlit_app.py
  lib/
    queries.py
    formatting.py
  pages/
    1_Open_Issues.py
    2_History.py

environment.yml pins packages available in the Snowflake Anaconda channel:

name: sf_env
channels:
  - snowflake
dependencies:
  - streamlit=1.*
  - snowflake-snowpark-python
  - pandas
  - altair

Anything not in that channel needs a different plan (vendored pure-Python code, or SPCS).

snowflake.yml defines the deployable entity for the Snowflake CLI:

definition_version: 2
entities:
  dq_triage:
    type: streamlit
    identifier:
      name: dq_triage
      schema: ops
      database: apps
    stage: apps.ops.app_files
    query_warehouse: wh_apps_xs
    main_file: streamlit_app.py
    pages_dir: pages/
    artifacts:
      - streamlit_app.py
      - environment.yml
      - lib/
      - pages/

Deploy from a laptop or from CI with the same command:

snow streamlit deploy --replace --connection prod dq_triage
snow streamlit get-url --connection prod dq_triage

That single command is the reason to use the CLI rather than editing in the UI: the app becomes a reviewable artifact in Git, promoted dev → prod like any other object. If you already run the Git-integration pattern (ALTER GIT REPOSITORY ... FETCH plus EXECUTE IMMEDIATE FROM), you can point CREATE STREAMLIT at a branch in the repository stage instead and skip the upload entirely.

Writing the app: get the session, then behave like a well-mannered client

import streamlit as st
from snowflake.snowpark.context import get_active_session

st.set_page_config(page_title="Data Quality Triage", layout="wide")
session = get_active_session()

@st.cache_data(ttl=300, show_spinner="Loading open issues…")
def load_open_issues(domain: str, min_severity: int):
    return (
        session.table("governance.dq.issue_log")
        .filter(
            (F.col("STATUS") == "OPEN")
            & (F.col("DOMAIN") == domain)
            & (F.col("SEVERITY") >= min_severity)
        )
        .sort(F.col("DETECTED_AT").desc())
        .limit(500)
        .to_pandas()
    )

domain   = st.sidebar.selectbox("Domain", ["orders", "billing", "inventory"])
severity = st.sidebar.slider("Minimum severity", 1, 5, 3)

df = load_open_issues(domain, severity)
st.metric("Open issues", len(df))
st.dataframe(df, use_container_width=True)

Three habits separate an app that costs $40/month from one that costs $4,000:

  • Cache everything with a TTL. Streamlit reruns the entire script on every widget interaction. Without @st.cache_data, a slider drag is a fresh query per frame.
  • Always LIMIT. Nobody scrolls 4 million rows. Aggregate in SQL, paginate in the UI.
  • Never call .to_pandas() on a wide, unfiltered table. Push filters into Snowpark so pruning happens in Snowflake.

For write-back, use a bounded, parameterised statement and let RBAC decide whether it is allowed:

if st.button("Acknowledge selected", type="primary"):
    ids = tuple(int(i) for i in selected_ids)
    session.sql(
        """
        UPDATE governance.dq.issue_log
           SET status = 'ACK', acked_by = CURRENT_USER(), acked_at = CURRENT_TIMESTAMP()
         WHERE issue_id IN (?)
        """,
        params=[ids],
    ).collect()
    st.cache_data.clear()
    st.success(f"Acknowledged {len(ids)} issues.")

CURRENT_USER() is the real viewer even in an owner's-rights app, so your audit trail stays honest. Grant INSERT/UPDATE on the write-back table only to the app role and only on that table.

Adding intelligence: Cortex calls from inside the app

Because the app already holds a session, an LLM feature is one SQL call away — no API key, no egress:

summary = session.sql(
    """
    SELECT SNOWFLAKE.CORTEX.COMPLETE(
             'claude-3-5-sonnet',
             CONCAT('Summarise these data quality issues for an on-call engineer '
                    'in five bullets, highest business impact first:\n', ?)
           )
    """,
    params=[df.head(50).to_csv(index=False)],
).collect()[0][0]

st.markdown(summary)

Put that behind a button rather than running it on every rerun — Cortex is billed per token, and a rerun loop can be expensive. The same pattern gives you CORTEX.SEARCH_PREVIEW for a document lookup pane or a Cortex Analyst call for natural-language filtering over a semantic view.

Reaching outside: external access integrations

Apps often need to post to Slack, open a Jira ticket, or hit an internal API. Do it through an external access integration, never a hardcoded token:

CREATE OR REPLACE NETWORK RULE jira_rule
  MODE = EGRESS TYPE = HOST_PORT
  VALUE_LIST = ('acme.atlassian.net:443');

CREATE OR REPLACE SECRET jira_token
  TYPE = GENERIC_STRING SECRET_STRING = '…';

CREATE OR REPLACE EXTERNAL ACCESS INTEGRATION jira_eai
  ALLOWED_NETWORK_RULES = (jira_rule)
  ALLOWED_AUTHENTICATION_SECRETS = (jira_token)
  ENABLED = TRUE;

ALTER STREAMLIT apps.ops.dq_triage
  SET EXTERNAL_ACCESS_INTEGRATIONS = (jira_eai)
      SECRETS = ('jira' = jira_token);

In Python, _snowflake.get_generic_secret_string('jira') retrieves it at runtime. The secret is a governed object with its own grants and audit trail, rotatable without redeploying the app.

Governance checklist before you share the URL

  • Rights model chosen deliberately. Caller's rights for anything governed by row access or masking policies; owner's rights only for apps whose entire dataset is safe for every viewer.
  • A dedicated app role per app, granted only the objects that app needs — not PUBLIC, not an analyst role.
  • A dedicated small warehouse (or a shared wh_apps_xs) so app spend is attributable. Tag it and put it under a budget.
  • A resource monitor or budget on that warehouse; a runaway rerun loop is the classic SiS bill surprise.
  • Query tagging for attribution: session.sql("ALTER SESSION SET QUERY_TAG = 'app:dq_triage'").collect() on startup, then slice SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY by tag.
  • No secrets in code. Secrets object or nothing.
  • Deployed from Git, so the reviewer diff exists and rollback is a redeploy of the previous commit.

Cost, realistically

Compute is the app's warehouse plus, on newer accounts, a small serverless charge for the app container itself. The dominant variable is rerun frequency. Instrument it early:

SELECT query_tag,
       COUNT(*)                                   AS queries,
       SUM(total_elapsed_time)/1000/60            AS minutes,
       SUM(credits_used_cloud_services)           AS cloud_svc_credits
  FROM snowflake.account_usage.query_history
 WHERE start_time > DATEADD('day', -7, CURRENT_TIMESTAMP())
   AND query_tag ILIKE 'app:%'
 GROUP BY 1
 ORDER BY minutes DESC;

If one app dominates the list, the fix is almost always caching TTLs and a missing LIMIT, not a bigger warehouse.

Where teams go wrong

  1. Owner's-rights apps over sensitive data. The single most common governance failure — masking policies silently do not apply to the viewer.
  2. Building a BI dashboard in Streamlit. If nobody clicks a button that changes something, you built a slower dashboard.
  3. Editing in the UI. Fine for a prototype; unreviewable and unrecoverable for anything with users.
  4. One giant streamlit_app.py. Split queries into lib/, use pages/, and unit-test the pure functions in CI against a dev account.
  5. No suspend. AUTO_SUSPEND = 600 on an app warehouse means you pay for idle all day.

Where to go next

A Streamlit app pairs naturally with the rest of the platform: a semantic view behind a Cortex Analyst pane, Snowflake Trail event tables for app-side logging, and the Native App Framework if the app should ship to other Snowflake accounts rather than just your own. Start with one workflow that people currently do in a spreadsheet, ship it in a week, and instrument it from day one.


Need help building or hardening Snowflake data apps? PowderInsights supplies senior Snowflake developers, architects, and consultants for cloud data warehouse and application work — including Streamlit in Snowflake apps, RBAC design, and cost control. Get in touch with your scope and timeline, or tell us about a partner or subcontracting need.