Every Snowflake shop eventually hits the same wall: you have built something genuinely useful — a churn scorer, a claims-normalisation pipeline, an industry benchmark — and now a customer, a sister business unit, or a partner wants to run it against their data. Emailing them a stack of SQL scripts is not a product, and shipping their data out to your account is usually a non-starter for legal.
The Snowflake Native App Framework exists for exactly this: you package application logic (SQL, Streamlit, Snowpark Python, even containers) into a versioned artifact, list it on the Marketplace or share it privately, and the consumer installs it into their own account. Their data never leaves. Your code is never readable by them.
This tutorial walks through the anatomy of a Native App, builds a small one end to end, and covers the parts teams usually get wrong: versioning, permissions, and upgrades.
Why the "app runs where the data is" model matters
The Native App inverts the usual SaaS shape:
| Classic SaaS | Native App | |
|---|---|---|
| Where the data goes | Customer exports to your platform | Nowhere — it stays in the consumer account |
| Where compute is billed | Your cloud bill | The consumer's Snowflake warehouse |
| Security review | Vendor risk assessment, DPAs, egress review | Install inside an existing trusted boundary |
| Your IP | Hosted, hidden | Hidden inside the app package; consumers can execute but not read |
| Distribution | Contracts, onboarding | Marketplace listing or private listing, install in minutes |
For consultancies and ISVs this is a real distribution channel, not just a packaging trick. It is also the cleanest way to hand internal capability to another business unit without granting cross-account access.
The moving parts
Three objects matter:
- Application package — the provider-side container. It holds versions, the setup script, and any shared content (data, models).
- Version / patch — an immutable snapshot of your code, e.g.
V1_0patch3. Consumers install a version; upgrades move them forward. - Application — the consumer-side installed object. Created by running your setup script inside their account.
Your source tree typically looks like:
my_app/
manifest.yml
setup.sql
README.md
streamlit/
app.py
python/
scoring.py
manifest.yml
The manifest is the app's contract: which setup script to run, what privileges the app will ask for, and what references (consumer tables, external endpoints) it needs.
manifest_version: 1
artifacts:
setup_script: setup.sql
readme: README.md
default_streamlit: app_schema.churn_ui
privileges:
- EXECUTE TASK:
description: "Refresh scores nightly"
- CREATE WAREHOUSE:
description: "Create a small dedicated warehouse for scoring runs"
references:
- consumer_orders:
label: "Orders table"
description: "Table containing one row per order"
privileges: [SELECT]
object_type: TABLE
register_callback: app_schema.register_reference
required_at_setup: true
Two ideas to internalise:
- Privileges are account-level capabilities the consumer explicitly grants after install (
EXECUTE TASK,CREATE WAREHOUSE,CREATE DATABASE). - References are how the app gets access to specific consumer objects. The consumer binds their table to your named reference through the UI or SQL; the app never gets blanket
SELECTon the account. This is the single most important design feature — treat every consumer object your app touches as a declared reference.
setup.sql
The setup script runs in the consumer's account at install and upgrade time. It must be idempotent, and it creates an application role — the only way consumers get to touch anything you build.
CREATE APPLICATION ROLE IF NOT EXISTS app_public;
CREATE OR ALTER VERSIONED SCHEMA app_schema;
GRANT USAGE ON SCHEMA app_schema TO APPLICATION ROLE app_public;
-- Callback that stores the consumer's binding for our reference
CREATE OR REPLACE PROCEDURE app_schema.register_reference(
ref_name STRING, operation STRING, ref_or_alias STRING)
RETURNS STRING
LANGUAGE SQL
AS $$
BEGIN
CASE (operation)
WHEN 'ADD' THEN SELECT SYSTEM$SET_REFERENCE(:ref_name, :ref_or_alias);
WHEN 'REMOVE' THEN SELECT SYSTEM$REMOVE_REFERENCE(:ref_name, :ref_or_alias);
WHEN 'CLEAR' THEN SELECT SYSTEM$REMOVE_ALL_REFERENCES(:ref_name);
END CASE;
RETURN 'ok';
END;
$$;
GRANT USAGE ON PROCEDURE app_schema.register_reference(STRING, STRING, STRING)
TO APPLICATION ROLE app_public;
-- Business logic reading the bound consumer table
CREATE OR REPLACE VIEW app_schema.order_summary AS
SELECT customer_id,
COUNT(*) AS order_count,
SUM(amount) AS lifetime_value,
MAX(order_date) AS last_order_date
FROM REFERENCE('consumer_orders')
GROUP BY customer_id;
GRANT SELECT ON VIEW app_schema.order_summary TO APPLICATION ROLE app_public;
-- Snowpark Python scoring routine shipped inside the app
CREATE OR REPLACE FUNCTION app_schema.churn_score(order_count INT, days_since_last INT)
RETURNS FLOAT
LANGUAGE PYTHON
RUNTIME_VERSION = '3.11'
HANDLER = 'scoring.score'
IMPORTS = ('/python/scoring.py');
GRANT USAGE ON FUNCTION app_schema.churn_score(INT, INT) TO APPLICATION ROLE app_public;
CREATE STREAMLIT IF NOT EXISTS app_schema.churn_ui
FROM '/streamlit'
MAIN_FILE = '/app.py';
GRANT USAGE ON STREAMLIT app_schema.churn_ui TO APPLICATION ROLE app_public;
CREATE OR ALTER VERSIONED SCHEMA is not decoration. A versioned schema is re-created per version, which is what makes upgrades safe: objects the consumer must keep (state tables, logs) go in a normal schema created with IF NOT EXISTS, while everything code-shaped goes in the versioned schema.
Building and testing with Snowflake CLI
Local iteration uses the Snowflake CLI (snow) and a snowflake.yml project file:
snow app run # creates/updates the package, uploads artifacts, installs in dev mode
snow app open # opens the installed app in Snowsight
snow app version create V1_0
snow app teardown
In dev mode the app installs directly from a stage, so an edit-and-rerun loop takes seconds. Once you are happy:
CREATE APPLICATION PACKAGE churn_app_pkg;
ALTER APPLICATION PACKAGE churn_app_pkg
ADD VERSION V1_0 USING '@dev_db.stage.churn_app';
-- Consumer-side install (or via a listing)
CREATE APPLICATION churn_app FROM APPLICATION PACKAGE churn_app_pkg USING VERSION V1_0;
GRANT APPLICATION ROLE churn_app.app_public TO ROLE analyst;
Shipping data with the app
Reference/benchmark data lives in the application package and is shared into the app:
CREATE SCHEMA churn_app_pkg.shared_content;
CREATE TABLE churn_app_pkg.shared_content.industry_benchmarks (industry STRING, churn_rate FLOAT);
GRANT USAGE ON SCHEMA churn_app_pkg.shared_content TO SHARE IN APPLICATION PACKAGE churn_app_pkg;
GRANT SELECT ON TABLE churn_app_pkg.shared_content.industry_benchmarks
TO SHARE IN APPLICATION PACKAGE churn_app_pkg;
Inside setup.sql you then expose a view over shared_content.industry_benchmarks. Consumers query the view; they cannot read the underlying table directly, and updates you publish flow to every installation.
Containers, when SQL and Python are not enough
If your product needs a long-running service, a non-Python runtime, or a GPU model endpoint, a Native App can run Snowpark Container Services inside the consumer account. Add a service spec to your artifacts, declare compute pool privileges in the manifest, and create the service from the setup script. Same isolation guarantees, much heavier footprint — reach for it only when a UDF, procedure, or Streamlit genuinely cannot do the job.
Upgrades: the part that bites
Consumers on auto-upgrade move to your newest patch. Your upgrade path runs through a version initializer:
CREATE OR ALTER VERSIONED SCHEMA app_schema;
CREATE OR REPLACE PROCEDURE app_schema.init()
RETURNS STRING LANGUAGE SQL
AS $$
BEGIN
-- runs on install AND upgrade; must be idempotent
CREATE TABLE IF NOT EXISTS app_state.run_log (run_ts TIMESTAMP_NTZ, rows_scored INT);
RETURN 'complete';
END;
$$;
Rules that save you incidents:
- Never drop or rename a persistent state object between versions; add columns, don't repurpose them.
- Test the upgrade, not just the install: install
V1_0, thenALTER APPLICATION ... UPGRADE USING VERSION V1_1in a scratch account. - Ship fixes as patches on an existing version; reserve new versions for behaviour changes.
- Log to an event table and ask consumers to enable sharing of app events — otherwise a failure in someone else's account is invisible to you.
Monetisation and distribution
Once the package is stable, attach it to a listing: private listing for named accounts (the common enterprise case) or Marketplace listing for public distribution, with free, paid, or trial pricing models handled by Snowflake billing. Cross-region and cross-cloud consumers require auto-fulfilment to be enabled on the provider account, which is worth setting up before your first customer asks.
Security review checklist before you list:
- Every consumer object accessed via a declared reference — no assumptions about object names.
- Minimum privileges in the manifest, each with a description a security reviewer will accept.
- No hard-coded warehouse names or role names.
- External network access declared explicitly (external access integration), or not used at all — many buyers reject apps that call out.
When a Native App is the wrong answer
- One consumer, one account. A share or a plain database is simpler.
- Read-only data product. Use a Marketplace data listing or secure data share.
- Fast-moving internal tooling. Native Apps enforce versioning discipline that internal teams often do not want yet; a Streamlit in Snowflake plus a Git-backed deployment pipeline is lighter.
Where to start
Take one internal artifact you already maintain for multiple stakeholders — a scoring model, a data quality pack, a benchmark set — and package it. The first app takes a week or two mostly spent on references and upgrade discipline; the second takes days.
If you would like help scoping a Native App, moving an existing Streamlit or Snowpark workload into a distributable package, or preparing a listing for security review, our Snowflake consultants and app developers do this work regularly — get in touch.