Skip to main content
Snowflake · lesson 9 of 11
Snowflake intermediate Lesson 9 of 11

Snowpark: Python DataFrames on Snowflake

Write Snowflake pipelines as Python DataFrame code instead of SQL strings, and use Snowflake's own local testing framework to run it without a warehouse.

By SkillByExample · Published

snowflake snowpark python dataframes

Every other lesson in this track writes SQL directly. Snowpark is Snowflake’s Python API: you build a query as chained DataFrame calls, and on a live account it compiles to one SQL statement that runs on a warehouse — the same warehouse, the same billing, the same query_history from the architecture lesson. This lesson uses Snowflake’s own local testing framework instead of a live account, so every example below runs with no warehouse and no cost — and it draws a hard line around exactly what that mode can and cannot show you.

A local testing session

from snowflake.snowpark import Session

session = Session.builder.config("local_testing", True).create()
print(session)
$ python session_setup.py
<snowflake.snowpark.session.Session: account="MOCK_ACCOUNT", role="MOCK_ROLE", database="MOCK_DATABASE", schema="MOCK_SCHEMA", warehouse="MOCK_WAREHOUSE">

local_testing=True is a real, first-party Snowpark feature for unit-testing pipelines offline — not a workaround. Every identifier reports MOCK_* because there is no account behind it; the DataFrame API underneath is the real one.

DataFrames are lazy — a plan, not a result

orders = session.create_dataframe(
    [(1001, 1, 25.50, "completed"), (1002, 2, 12.00, "completed"),
     (1003, 1, 40.00, "returned"),  (1004, 3, 8.75, "completed"),
     (1005, 2, 63.20, "pending")],
    schema=["order_id", "customer_id", "amount", "status"],
)

completed = orders.filter(orders["status"] == "completed")
print(type(completed))
$ python lazy_plan.py
<class 'snowflake.snowpark.dataframe.DataFrame'>

filter() returned immediately with another DataFrame — no rows were touched. Nothing runs until you call .show() or .collect(), exactly like building a SQL query string before executing it.

completed.show()
-----------------------------------------------------
|"ORDER_ID"  |"CUSTOMER_ID"  |"AMOUNT"  |"STATUS"   |
-----------------------------------------------------
|1001        |1              |25.5      |completed  |
|1002        |2              |12.0      |completed  |
|1004        |3              |8.75      |completed  |
-----------------------------------------------------

Aggregating like SQL, chained like Python

from snowflake.snowpark.functions import col, sum as sql_sum, count

revenue_by_customer = (
    orders
    .filter(col("status") == "completed")
    .group_by("customer_id")
    .agg(sql_sum(col("amount")).alias("total"), count("*").alias("orders"))
    .sort("customer_id")
)
revenue_by_customer.show()
$ python aggregate.py
--------------------------------------
|"CUSTOMER_ID"  |"TOTAL"  |"ORDERS"  |
--------------------------------------
|1              |25.5     |1         |
|2              |12.0     |1         |
|3              |8.75     |1         |
--------------------------------------

Read this as the group by/sum/count SQL from the architecture lesson written as method calls instead of a string — on a live account, that is literally what it becomes.

Python UDFs run locally too

from snowflake.snowpark.functions import udf
from snowflake.snowpark.types import StringType

@udf(name="tier_for_amount", return_type=StringType(), replace=True)
def tier_for_amount(amount: float) -> str:
    if amount >= 40:
        return "gold"
    if amount >= 15:
        return "silver"
    return "bronze"

orders.select(col("order_id"), tier_for_amount(col("amount")).alias("tier")).show()
$ python udf.py
-----------------------
|"ORDER_ID"  |"TIER"  |
-----------------------
|1001        |silver  |
|1002        |bronze  |
|1003        |gold    |
|1004        |bronze  |
|1005        |gold    |
-----------------------

@udf registers a real Python function as a callable inside DataFrame expressions. On a live account this ships the function to Snowflake and runs it server-side, next to the data — local testing runs it in-process instead, which is why the logic is trustworthy to unit-test this way even though the execution location isn’t real.

Two real limits of local testing

Predict what happens if you drop to raw SQL:

try:
    session.sql("select 1").collect()
except NotImplementedError as e:
    print(f"{type(e).__name__}: {e}")
$ python raw_sql.py
NotImplementedError: [Local Testing] Session.sql is not supported.

Local testing only emulates the DataFrame API — not a SQL engine. Any pipeline that drops to session.sql() for something the DataFrame API doesn’t cover needs a live account to test.

The second limit is subtler. Ask a DataFrame for the SQL it would run:

print(completed.queries)
$ python inspect_queries.py
{'queries': ['SELECT MOCK_TEST_FAKE_QUERY()'], 'post_actions': []}

On a live account, .queries returns the actual compiled SQL — genuinely useful for understanding what Snowflake will execute and for EXPLAIN-style review before running something expensive. Local testing can’t show it, because there’s no real compiler behind this session: the placeholder is Snowpark telling you, honestly, “this part isn’t real.” Trust local testing for pipeline logic; verify the generated SQL and its cost against a real warehouse before it goes anywhere near production.

Practice

1. Filter orders to just the "pending" status and show the result.
orders.filter(col("status") == "pending").show()
----------------------------------------------------
|"ORDER_ID"  |"CUSTOMER_ID"  |"AMOUNT"  |"STATUS"  |
----------------------------------------------------
|1005        |2              |63.2      |pending   |
----------------------------------------------------
2. Compute the average order amount per status using avg.
from snowflake.snowpark.functions import avg

orders.group_by("status").agg(avg(col("amount")).alias("avg_amount")).sort("status").show()
----------------------------------
|"STATUS"   |"AVG_AMOUNT"        |
----------------------------------
|completed  |15.416666666666666  |
|pending    |63.2                |
|returned   |40.0                |
----------------------------------

avg() over three completed orders (25.50, 12.00, 8.75) gives the full unrounded float — the same result SQL’s avg() would return, not something Snowpark rounds for display.

3. Convert the revenue-by-customer DataFrame to pandas and check its type.
pdf = revenue_by_customer.to_pandas()
print(type(pdf))
print(pdf)
<class 'pandas.DataFrame'>
   CUSTOMER_ID  TOTAL  ORDERS
0            1  25.50       1
1            2  12.00       1
2            3   8.75       1

.to_pandas() is the bridge back to eager, in-memory pandas — use it once you’ve filtered Snowflake-side down to something small enough to actually want in local memory.

4. Try calling tier_for_amount directly with a plain Python number instead of a Column.
print(tier_for_amount(50))
Traceback (most recent call last):
  ...
TypeError: The input of UDF "MOCK_DATABASE"."MOCK_SCHEMA"."TIER_FOR_AMOUNT" must be Column, column name, or a list of them

@udf does not stop you from calling the wrapper — it stops you from calling it with a plain value. It expects a Column expression built from a DataFrame, like col("amount") inside .select(). To run the underlying Python function directly with a plain number for a quick sanity check, reach past the wrapper: tier_for_amount.func(50) returns 'gold'.

Next: query profiling and cost control — finding the query that is spending your budget.

Frequently Asked Questions

What is Snowpark?
Snowflake's Python (and Java/Scala) API for building DataFrame pipelines that run inside Snowflake instead of pulling data to a client. A Snowpark DataFrame is lazy — chaining `.filter()`/`.select()`/`.group_by()` builds a query plan, and on a real account that plan compiles to a single SQL statement executed by a warehouse, the same way the SQL you'd write by hand would.
Do I need a Snowflake account to learn Snowpark?
Not to learn the DataFrame API. Snowflake ships an official local testing mode (`Session.builder.config("local_testing", True)`) that runs DataFrame transformations, aggregations and Python UDFs entirely in-process — no account, no warehouse, no cost. It does not run raw SQL or show the real generated SQL plan; both need a live account, covered in this lesson.
Is a Snowpark DataFrame the same as a pandas DataFrame?
No, and confusing them is the most common Snowpark mistake. A pandas DataFrame is eager and lives in your process's memory. A Snowpark DataFrame is lazy — building one does no work — and on a real account, `.collect()` or `.show()` triggers Snowflake to run the compiled SQL on a warehouse and return only the result rows. Call `.to_pandas()` when you actually need the data client-side.
Why would I use Snowpark instead of just writing SQL?
Composability and testability. DataFrame operations are Python values you can build with loops and functions, unit test without a live warehouse (via local testing mode), and share logic with UDFs — while a hand-built SQL string has to be assembled and validated at runtime. The trade-off is an extra layer between you and the SQL Snowflake actually runs.