Tutorial: Run Cortex batch inference over a Snowflake table

This tutorial shows how to partition a Snowflake table across multiple sandboxes running in parallel, call SNOWFLAKE.CORTEX.COMPLETE inside each sandbox via SQL, and collect the results. Each sandbox authenticates using the session identity Snowflake injects automatically — no credentials are forwarded from your driver.

When you finish, you will have a working pattern for any workload that needs to apply an LLM to rows in a Snowflake table at scale.

Time to complete: about 20 minutes.

Prerequisites

  • The snowflake-sandbox-python SDK installed and a Snowflake connection configured. See Cortex Sandboxes quickstart.
  • No image build and no staging stage are involved: the sandboxes launch from the sandbox-base catalog image, and scorer.py is uploaded into each one at run time. Every statement below names its database and schema explicitly, so your connection does not need a default database or schema.
  • Your role must have USAGE on the Cortex COMPLETE function. Ask your account administrator if you are unsure.

Step 1: Set up the demo tables

Create a PRODUCT_REVIEWS table with a handful of sample reviews and an empty REVIEW_SENTIMENTS table to hold the results. Replace <your_database> and <your_schema> with your own database and schema.

CREATE TABLE <your_database>.<your_schema>.PRODUCT_REVIEWS (
    review_id   NUMBER,
    product     VARCHAR,
    review_text VARCHAR
);

INSERT INTO <your_database>.<your_schema>.PRODUCT_REVIEWS VALUES
(1, 'Wireless Headphones', 'Absolutely love these headphones — clear sound and great battery life.'),
(2, 'Wireless Headphones', 'Fell apart after two weeks. Very disappointed with the build quality.'),
(3, 'Standing Desk',       'Solid desk, easy to assemble, much better for my back.'),
(4, 'Standing Desk',       'The motor is loud and the height memory buttons stopped working.'),
(5, 'USB-C Hub',           'Works perfectly with my laptop. Fast charging and no overheating.'),
(6, 'USB-C Hub',           'Stopped recognising USB devices after a firmware update. Avoid.');

CREATE TABLE <your_database>.<your_schema>.REVIEW_SENTIMENTS (
    review_id       NUMBER,
    product         VARCHAR,
    sentiment       VARCHAR,
    sentiment_score FLOAT,
    scored_at       TIMESTAMP_LTZ DEFAULT CURRENT_TIMESTAMP()
);

Step 2: Write the scorer script

This is the code that runs inside each sandbox. Save it as scorer.py. It receives the database name, schema name, and row range as command-line arguments, queries PRODUCT_REVIEWS, calls SNOWFLAKE.CORTEX.COMPLETE per row, and returns results as JSON on stdout.

It connects to Snowflake with get_snowflake_connection(), which resolves the session token the platform provides — no credential configuration is required.

# scorer.py  — runs inside each sandbox
import sys, json

from snowflake.sandbox import get_snowflake_connection

DB, SCHEMA, start_id, end_id = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4])

conn = get_snowflake_connection()
cur = conn.cursor()

cur.execute(
    f"SELECT review_id, product, review_text "
    f"FROM {DB}.{SCHEMA}.PRODUCT_REVIEWS "
    f"WHERE review_id BETWEEN %s AND %s ORDER BY review_id",
    (start_id, end_id),
)
rows = cur.fetchall()

results = []
for review_id, product, review_text in rows:
    prompt = (
        "Classify the sentiment of this product review. "
        'Return JSON only: {"sentiment": "positive|neutral|negative", "score": 0.0-1.0}. '
        "Review: " + review_text
    )
    cur.execute("SELECT SNOWFLAKE.CORTEX.COMPLETE('llama3.1-8b', %s)", (prompt,))
    raw = (cur.fetchone() or ["{}"])[0].strip()
    # Strip markdown fences that some models add despite the prompt
    if raw.startswith("```"):
        raw = raw.strip("`").lstrip("json").strip()
    try:
        parsed = json.loads(raw)
    except Exception:
        parsed = {"sentiment": "unknown", "score": 0.0}
    results.append({
        "review_id": review_id,
        "product": product,
        "sentiment": parsed.get("sentiment", "unknown"),
        "score": float(parsed.get("score", 0.0)),
    })
    # Progress line written to stderr — streamed live to the driver without
    # mixing with the JSON result that the driver reads from stdout.
    print(f"scored review {review_id} ({product}): {parsed.get('sentiment', 'unknown')}", file=sys.stderr, flush=True)

conn.close()
print(json.dumps(results))

Replace llama3.1-8b with any Cortex model your role has access to. See COMPLETE (SNOWFLAKE.CORTEX) for the list of available models.

Step 3: Write the driver

The driver fans out three sandboxes in parallel (one per row range), sends scorer.py into each, and streams live output from each sandbox as rows are scored. Save this as main.py alongside scorer.py.

# main.py  — runs on your machine (the driver)
import json, snowflake.connector
from concurrent.futures import ThreadPoolExecutor
from snowflake.sandbox import Image, Sandbox

CONNECTION = "<your_connection_name>"  # the connection name from your connections.toml
DB     = "<your_database>"
SCHEMA = "<your_schema>"

def score_chunk(label: str, start_id: int, end_id: int) -> list:
    with Sandbox.create(
        image=Image.from_catalog("sandbox-base"), connection=CONNECTION
    ) as sb:
        # Send the scorer into this sandbox. scorer.py needs only
        # snowflake-connector-python, which the base image already provides.
        sb.upload_file("scorer.py", "/tmp/scorer.py")
        stream = sb.exec_stream(
            ["python", "/tmp/scorer.py", DB, SCHEMA, str(start_id), str(end_id)],
            timeout=120.0,
        )
        stdout_lines: list[str] = []
        for line in stream:
            if line.stream == "stderr":  # progress from scorer.py
                print(f"  [{label}] {line.data}")
            else:  # stdout: collect for JSON parsing
                stdout_lines.append(line.data)
        if stream.exit_code != 0:
            raise RuntimeError(f"[{label}] exited {stream.exit_code}")
        # The JSON result is the last NON-EMPTY stdout line. Do not use
        # stdout_lines[-1]: the stream ends with a trailing empty line, so
        # indexing the last element parses "" and raises JSONDecodeError.
        payload = next(s for s in reversed(stdout_lines) if s.strip())
        return json.loads(payload)

print("Starting 3 sandboxes in parallel...")
with ThreadPoolExecutor() as pool:
    futures = [
        pool.submit(score_chunk, "sb-1", 1, 2),
        pool.submit(score_chunk, "sb-2", 3, 4),
        pool.submit(score_chunk, "sb-3", 5, 6),
    ]
    chunks = [f.result() for f in futures]
results = [row for chunk in chunks for row in chunk]

# Write results back to Snowflake using the same connection as the driver
conn = snowflake.connector.connect(connection_name=CONNECTION)
cur = conn.cursor()
for r in results:
    cur.execute(
        f"INSERT INTO {DB}.{SCHEMA}.REVIEW_SENTIMENTS "
        f"(review_id, product, sentiment, sentiment_score) VALUES (%s, %s, %s, %s)",
        (r["review_id"], r["product"], r["sentiment"], r["score"]),
    )
conn.close()

print(f"\n{'ID':<4} {'Product':<22} {'Sentiment':<12} {'Score'}")
print("-" * 55)
for r in sorted(results, key=lambda x: x["review_id"]):
    print(f"{r['review_id']:<4} {r['product']:<22} {r['sentiment']:<12} {r['score']:.2f}")
print(f"\n{len(results)} reviews scored and written to {DB}.{SCHEMA}.REVIEW_SENTIMENTS")

How to run this example

Save scorer.py and main.py in the same directory and run:

python main.py

There is no image build step: every run starts the three sandboxes from the sandbox-base catalog image and uploads scorer.py into each of them.

Expected output:

Starting 3 sandboxes in parallel...
  [sb-1] scored review 1 (Wireless Headphones): positive
  [sb-3] scored review 5 (USB-C Hub): positive
  [sb-2] scored review 3 (Standing Desk): positive
  [sb-1] scored review 2 (Wireless Headphones): negative
  [sb-3] scored review 6 (USB-C Hub): negative
  [sb-2] scored review 4 (Standing Desk): negative

ID   Product                Sentiment    Score
-------------------------------------------------------
1    Wireless Headphones    positive     0.90
2    Wireless Headphones    negative     0.80
3    Standing Desk          positive     0.80
4    Standing Desk          negative     0.60
5    USB-C Hub              positive     0.90
6    USB-C Hub              negative     0.80

6 reviews scored and written to <your_database>.<your_schema>.REVIEW_SENTIMENTS

All three sandboxes run concurrently, so the total time is roughly the time of one chunk — not three.

What this demonstrates

  • No credentials are forwarded. Each sandbox connects to Snowflake using only the session token the platform binds at /snowflake/session/token. Your PAT is not passed to the container.
  • The scorer is uploaded, not inlined. sb.upload_file() puts scorer.py at a writable path inside each sandbox, so the exec command stays a clean, readable path: ["python", "/tmp/scorer.py", ...]. This avoids passing a large script as a shell -c argument. Each sandbox uploads its own copy, which takes well under a second and happens in parallel with the others. Note the /tmp prefix: the sandbox refuses writes to the filesystem root, so /scorer.py would fail. The server answers with a 403, which the SDK surfaces as SandboxAuthError — so a path mistake here reads as an authentication failure rather than a path one.
  • Cortex runs in-warehouse. SNOWFLAKE.CORTEX.COMPLETE is a Snowflake SQL function — the LLM call is executed by Snowflake’s compute, not by the sandbox container itself.
  • Streaming output from parallel sandboxes. exec_stream() yields lines as each sandbox produces them. The interleaved output in the expected output above — lines from sb-1, sb-2, and sb-3 arriving out of order — reflects the three sandboxes running genuinely concurrently. Use exec() when you only need the final result; use exec_stream() when you want to observe progress or handle long-running commands.
  • Governance applies. Each sandbox inherits your session identity. Row-level security and column masking policies apply to the data each sandbox reads, exactly as they would for a query you run directly. See Tutorial: Governance enforcement in a sandbox.
  • Scale by adding chunks. Replace the three pool.submit calls with a loop over however many row ranges you need. Each sandbox is independent and the concurrency is bounded only by your account limits.