+1 (415) 997-4269

Snowpark Container Services: Running Your Own Containers (and Model Endpoints) Inside Snowflake

Snowpark Container Services (SPCS) lets you run any OCI container image inside your Snowflake account, on Snowflake-managed compute pools, with the data never leaving the security perimeter. It is the piece that closes the gap between "SQL and Python UDFs" and "we need to run an actual application" — a FastAPI service, a Streamlit-plus-backend app, a vector search sidecar, a fine-tuned model endpoint, or a legacy Java process that nobody wants to rewrite.

This tutorial walks a container from a local Dockerfile to a running, callable service, and then shows the two ways to consume it: as an HTTP endpoint and as a service function you can call from ordinary SQL.

When SPCS is the right answer

Reach for Snowpark Container Services when at least one of these is true:

  • The workload needs a runtime Snowflake doesn't give you. Arbitrary system packages, a C++ binary, a Node service, a specific CUDA build.
  • The workload needs a GPU. Compute pools come in GPU families; Python UDFs do not.
  • The workload is long-running or stateful-ish. A model server that loads 8 GB of weights once and answers thousands of calls, rather than paying cold-start on every UDF invocation.
  • The data must not leave the account. Sending customer records to an external inference API is a governance conversation; running the same model in SPCS is not.

Do not reach for it when a vectorized Python UDF, a stored procedure, or a Cortex function would do. SPCS bills for compute-pool node time whether or not anything is calling your service; a UDF bills for the warehouse seconds it actually consumes. Most "we need containers" requests we get in consulting engagements turn out to be a AI_COMPLETE() call or a Snowpark stored procedure once the requirement is written down.

Step 1 — the objects you need first

Four objects, created once per environment. Do this as ACCOUNTADMIN (or a role with the relevant grants) and then hand ownership to a service-owning role.

USE ROLE ACCOUNTADMIN;

CREATE ROLE IF NOT EXISTS spcs_app_role;
CREATE DATABASE IF NOT EXISTS apps;
CREATE SCHEMA IF NOT EXISTS apps.scoring;

-- 1. A compute pool: the VMs your containers run on
CREATE COMPUTE POOL IF NOT EXISTS scoring_pool
  MIN_NODES = 1
  MAX_NODES = 2
  INSTANCE_FAMILY = CPU_X64_S
  AUTO_SUSPEND_SECS = 600;

-- 2. An image repository: a private OCI registry inside Snowflake
CREATE IMAGE REPOSITORY IF NOT EXISTS apps.scoring.images;

-- 3. A stage for the service specification file
CREATE STAGE IF NOT EXISTS apps.scoring.specs
  DIRECTORY = (ENABLE = TRUE)
  ENCRYPTION = (TYPE = 'SNOWFLAKE_SSE');

GRANT USAGE ON DATABASE apps TO ROLE spcs_app_role;
GRANT USAGE ON SCHEMA apps.scoring TO ROLE spcs_app_role;
GRANT USAGE, MONITOR ON COMPUTE POOL scoring_pool TO ROLE spcs_app_role;
GRANT READ, WRITE ON IMAGE REPOSITORY apps.scoring.images TO ROLE spcs_app_role;
GRANT READ, WRITE ON STAGE apps.scoring.specs TO ROLE spcs_app_role;
GRANT BIND SERVICE ENDPOINT ON ACCOUNT TO ROLE spcs_app_role;  -- only if you want public endpoints

AUTO_SUSPEND_SECS on the compute pool is the single most important cost knob on this page. A pool with no auto-suspend and a MIN_NODES = 2 is a bill that arrives every hour of every day whether anyone uses the service or not.

Step 2 — build and push the image

A minimal scoring service. Nothing Snowflake-specific in the app itself:

# app.py
from fastapi import FastAPI, Request
import joblib, os

app = FastAPI()
model = joblib.load("/app/model.joblib")

@app.get("/healthcheck")
def healthcheck():
    return {"status": "ok"}

@app.post("/score")
async def score(request: Request):
    payload = await request.json()
    rows = payload["data"]                     # [[row_index, arg1, arg2, ...], ...]
    out = []
    for row in rows:
        idx, *features = row
        pred = float(model.predict([features])[0])
        out.append([idx, pred])
    return {"data": out}

The {"data": [[index, ...], ...]} shape is not arbitrary: it is the contract Snowflake uses when it calls a container through a service function. Row index first, then your values; return the same index with your result.

FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py model.joblib .
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

Build for linux/amd64 — this is the number one first-attempt failure for anyone on an Apple Silicon laptop, and the symptom is an opaque exec format error in the service logs.

REPO=$(snow spcs image-repository url apps.scoring.images)

docker build --platform linux/amd64 -t $REPO/scorer:1.0.0 .
snow spcs image-registry login
docker push $REPO/scorer:1.0.0

Tag with a real version, never latest. Service upgrades are declarative; if the tag never changes you lose the ability to say what is actually running, and rollback becomes guesswork.

Step 3 — the service specification

The spec is YAML, uploaded to a stage (or passed inline via FROM SPECIFICATION).

# scorer.yaml
spec:
  containers:
    - name: scorer
      image: /apps/scoring/images/scorer:1.0.0
      env:
        MODEL_THRESHOLD: "0.62"
      resources:
        requests:
          memory: 2G
          cpu: 1
        limits:
          memory: 4G
          cpu: 2
      readinessProbe:
        port: 8000
        path: /healthcheck
  endpoints:
    - name: api
      port: 8000
      public: false

Two fields worth dwelling on:

  • readinessProbe — Snowflake will not route traffic until this returns 200. Without it, the first calls after a deployment fail while your model is still loading from disk.
  • public: false — keep it false unless a browser outside Snowflake genuinely needs to reach the service. Public endpoints require BIND SERVICE ENDPOINT and authenticate through Snowflake, but the smallest attack surface is the one you don't create.

Upload and create:

USE ROLE spcs_app_role;
PUT file://scorer.yaml @apps.scoring.specs OVERWRITE = TRUE AUTO_COMPRESS = FALSE;

CREATE SERVICE apps.scoring.scorer_svc
  IN COMPUTE POOL scoring_pool
  FROM @apps.scoring.specs
  SPECIFICATION_FILE = 'scorer.yaml'
  MIN_INSTANCES = 1
  MAX_INSTANCES = 1
  QUERY_WAREHOUSE = compute_wh;

Step 4 — watch it start (and debug it when it doesn't)

SELECT SYSTEM$GET_SERVICE_STATUS('apps.scoring.scorer_svc');

SELECT SYSTEM$GET_SERVICE_LOGS('apps.scoring.scorer_svc', 0, 'scorer', 100);

SHOW ENDPOINTS IN SERVICE apps.scoring.scorer_svc;
DESCRIBE SERVICE apps.scoring.scorer_svc;

A short triage table for the failures we see most often:

SymptomUsual cause
PENDING foreverCompute pool has no free node, or INSTANCE_FAMILY too small for the container's requests
Container restarts in a loopProcess exits immediately — check CMD, check the port matches the endpoint
exec format errorImage built for arm64; rebuild with --platform linux/amd64
Service healthy, function times outReadiness probe missing or the /score path in the function definition is wrong
Image not foundPushed to a different repo/tag than the spec references; SHOW IMAGES IN IMAGE REPOSITORY to confirm

Route logs and metrics into an event table so you are not reading them 100 lines at a time:

ALTER ACCOUNT SET EVENT_TABLE = observability.public.events;
ALTER SERVICE apps.scoring.scorer_svc SET LOG_LEVEL = 'INFO';

Step 5 — call it from SQL

This is the part that makes SPCS feel native. A service function binds a SQL function to an HTTP path on the service:

CREATE OR REPLACE FUNCTION apps.scoring.predict_churn(tenure FLOAT, monthly FLOAT, support_calls FLOAT)
  RETURNS FLOAT
  SERVICE = apps.scoring.scorer_svc
  ENDPOINT = 'api'
  MAX_BATCH_ROWS = 500
  AS '/score';

Now it is just SQL, and it participates in everything SQL participates in — views, dynamic tables, masking policies, GRANT:

SELECT
  customer_id,
  apps.scoring.predict_churn(tenure_months, monthly_spend, support_calls) AS churn_score
FROM analytics.customer_features
WHERE churn_score > 0.62;

GRANT USAGE ON FUNCTION apps.scoring.predict_churn(FLOAT, FLOAT, FLOAT) TO ROLE analyst;

MAX_BATCH_ROWS controls how many rows Snowflake packs into each HTTP request. Too low and you pay per-request overhead on every batch; too high and a big scan can blow the container's memory. 200–1000 is a sane starting band; measure with a representative table rather than guessing.

If the container needs to query Snowflake itself, it does not need a password — read the OAuth token that Snowflake mounts into every container:

with open("/snowflake/session/token", "r") as f:
    token = f.read()
# connect with authenticator="oauth", token=token,
# host=os.environ["SNOWFLAKE_HOST"], account=os.environ["SNOWFLAKE_ACCOUNT"]

Queries issued this way run as the service's owner role against QUERY_WAREHOUSE. Give that role the narrowest grants that let the job finish.

Step 6 — upgrades and cost hygiene

Deployments are declarative: push a new tag, edit the spec, and re-point the service.

ALTER SERVICE apps.scoring.scorer_svc
  FROM @apps.scoring.specs
  SPECIFICATION_FILE = 'scorer.yaml';   -- now referencing scorer:1.1.0

Rollback is the same command with the previous spec file, which is why versioned tags and a spec file in Git matter.

Then the housekeeping that keeps SPCS from becoming a line item nobody can explain:

-- What is running, and on what
SHOW SERVICES IN ACCOUNT;
SHOW COMPUTE POOLS;

-- What it cost
SELECT service_type, name, SUM(credits_used) AS credits
FROM snowflake.account_usage.metering_history
WHERE start_time > DATEADD('day', -30, CURRENT_TIMESTAMP())
  AND service_type = 'SNOWPARK_CONTAINER_SERVICES'
GROUP BY 1, 2 ORDER BY credits DESC;

-- Stop paying for an idle experiment
ALTER SERVICE apps.scoring.scorer_svc SUSPEND;
ALTER COMPUTE POOL scoring_pool SUSPEND;

Rules of thumb we apply on client accounts:

  1. One compute pool per workload class, not one per service — pools are the billing unit and small idle pools multiply.
  2. AUTO_SUSPEND_SECS on every non-production pool, always.
  3. Batch scoring jobs should suspend their pool at the end of the job, in the same task that runs the scoring.
  4. Alert on metering_history for SNOWPARK_CONTAINER_SERVICES — it is the credit category most likely to drift unnoticed because no query shows up in query_history.

Where this goes next

Once a service is running, two natural extensions follow. First, packaging it as a Snowflake Native App so the container ships to a customer account and runs against their data without either party moving the data. Second, exposing the service to Cortex Agents as a tool, so an agent can call your bespoke model alongside Cortex Analyst and Cortex Search. Both build on exactly the objects above: image repository, spec, compute pool, service.

Start smaller than that. Get one container running with a readiness probe, one service function, and a suspended pool at the end of the day. That combination proves the pattern, and it is usually enough to answer the question the business actually asked.


PowderInsights supplies senior Snowflake architects and developers for exactly this kind of work — containerized ML serving, Native App packaging, migration and cost work. If you have a project scope or a staffing need, get in touch and describe the timeline and the role.