Skip to content

SNOW-3472759: support for to_polars() for snowpark -> polars interoperability - #4288

Open
sfc-gh-mayliu wants to merge 8 commits into
mainfrom
SNOW-3472759-support-to-polars
Open

SNOW-3472759: support for to_polars() for snowpark -> polars interoperability#4288
sfc-gh-mayliu wants to merge 8 commits into
mainfrom
SNOW-3472759-support-to-polars

Conversation

@sfc-gh-mayliu

@sfc-gh-mayliu sfc-gh-mayliu commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator
  1. Which Jira issue is this PR addressing? Make sure that there is an accompanying issue to your PR.

    Fixes SNOW-3472759

  2. Fill out the following pre-review checklist:

    • I am adding a new automated test(s) to verify correctness of my new code
      • If this test skips Local Testing mode, I'm requesting review from @snowflakedb/local-testing
    • I am adding new logging messages
    • I am adding a new telemetry message
    • I am adding new credentials
    • I am adding a new dependency
    • If this is a new feature/behavior, I'm adding the Local Testing parity changes.
    • I acknowledge that I have ensured my changes to be thread-safe. Follow the link for more information: Thread-safe Developer Guidelines
    • If adding any arguments to public Snowpark APIs or creating new public Snowpark APIs, I acknowledge that I have ensured my changes include AST support. Follow the link for more information: AST Support Guidelines
  3. Please describe how your code solves the related issue.

    Please write a short description of how your code change solves the related issue.

This PR adds DataFrame.to_polars() to enable direct conversion from a Snowpark DataFrame to a Polars DataFrame/LazyFrame

@codecov-commenter

codecov-commenter commented Jul 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.27%. Comparing base (a85d5e8) to head (54bd11c).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4288      +/-   ##
==========================================
- Coverage   95.46%   95.27%   -0.20%     
==========================================
  Files         171      172       +1     
  Lines       44720    44804      +84     
  Branches     7676     7686      +10     
==========================================
- Hits        42694    42689       -5     
- Misses       1253     1299      +46     
- Partials      773      816      +43     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@sfc-gh-mayliu
sfc-gh-mayliu requested a review from a team as a code owner July 20, 2026 23:20
Comment thread src/snowflake/snowpark/dataframe.py Outdated
@sfc-gh-mayliu
sfc-gh-mayliu requested a review from a team July 21, 2026 18:35
Comment thread src/snowflake/snowpark/dataframe.py Outdated

@snowflake-security-bot snowflake-security-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Snowflake Security Review

Security grade: A — Passed

No security findings after adjudication. This PR passes the Snowflake Security Review.

📊 5 of 6 files (1 test skipped) · 11,645 lines reviewed · 4 candidates → 0 kept · retrieval: on

)
# The stream objects are owned by the returned LazyFrame; Polars closes them
# when the scan is materialized (or if the LazyFrame is discarded).
streams = _open_stage_files_parallel(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i have a question about this ingestion path.
Since this path is ingesting with a stream of parquet file,
if I call collect() two times, will the second collect() still be able to fetch anything since the stream is already consumed at that time?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both session.file.get_stream and SnowflakeFile.open eagerly downloads/buffers, so the multiple Polars .collect() calls are safe to get the predicate/projection pruned data on CPU without needing to re-download. However, it's a Snowflake infra-limitation that network I/O pruning costs can't be saved due to the lack of HTTP Range GET support in session.file and SnowflakeFile

Tested with 1M rows: first .collect() takes 0.04s, second .collect() takes 0.03s — effectively the same cpu-decode cost with no additional network I/O.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this sounds good!

rid = uuid.uuid4().hex[:12]
stage = df._session.get_session_stage().rstrip("/")
sub = f"{stage}/{sub_prefix}/{rid}/"
df.write.copy_into_location(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it appears like the uploaded parquet file is never removed, do we need a garbage cleaning mechanism to make sure the stage is not filled when user invoke this ingestion route multiple times?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_session_stage() returns a temporary stage that Snowflake auto-drops on session close, so no need to recycle here

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am thinking about a scenario in which user have a long running pipeline that call to_polars() in this ingestion route, in that scenario, the stage will keep accumulating uploaded parquet files. Do we need to worry about this scenario or is this not a expected usage?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, discussed offline. TLDR: long-lived sessions exist but are rare, and there's no storage limit on temp stages. sproc also rejects REMOVE {file} calls, so a clean up would result in inconsistency between local and server environments. We will rely on temp stages' property to clean up after itself, similar to existing Snowpark patterns

overwrite=True,
statement_params=statement_params,
)
rows = df._session.sql(f"LIST '{sub}'").collect(statement_params=statement_params)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I remember that List would not return fully qualified stage name, should we make it fully qualified before return?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, LS returns stage-relative paths and we should make it fully qualified. We already have the fully-qualified sub prefix at hand, so the fix is to extract just the filename from the LIST result and prepend sub instead. Fix incoming.

@snowflake-security-bot snowflake-security-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Snowflake Security Review

Security grade: A — Passed

No security findings after adjudication. This PR passes the Snowflake Security Review.

📊 5 of 6 files (1 test skipped) · 11,646 lines reviewed · 4 candidates → 0 kept · retrieval: on

Comment on lines +34 to +99
) -> list:
"""Open stage files concurrently and return file-like objects.

Uses ``SnowflakeFile.open`` inside a stored procedure (no local /tmp
materialization) and ``session.file.get_stream`` on the client.
"""
if is_sproc:
from snowflake.snowpark.files import SnowflakeFile

def _open(p: str):
return SnowflakeFile.open(p, "rb", require_scoped_url=False)

else:

def _open(p: str):
return session.file.get_stream(p)

if not paths:
return []
with ThreadPoolExecutor(max_workers=max_workers) as ex:
return list(ex.map(_open, paths))


def _open_and_read_parquet_parallel(
session,
paths: list[str],
is_sproc: bool,
max_workers: int | None = None,
) -> list[pl.DataFrame]:
"""Open and read each stage Parquet file in a thread pool.

Opens and decodes run together per file so the read step is not serialized
after a parallel open — the decode releases the GIL for I/O and native
Arrow work.
"""
import polars as pl

if is_sproc:
from snowflake.snowpark.files import SnowflakeFile

def _open_read(p: str):
f = SnowflakeFile.open(p, "rb", require_scoped_url=False)
try:
return pl.read_parquet(f)
finally:
try:
f.close()
except Exception as e:
_logger.warning("failed to close stage file %s: %s", p, e)

else:

def _open_read(p: str):
f = session.file.get_stream(p)
try:
return pl.read_parquet(f)
finally:
try:
f.close()
except Exception as e:
_logger.warning("failed to close stage file %s: %s", p, e)

if not paths:
return []
with ThreadPoolExecutor(max_workers=max_workers) as ex:
return list(ex.map(_open_read, paths))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a styling issue -- I'm seeing repeated code structures here in 2 ways:

  • within the function, two similar _open_read, one for sproc, one for non-sproc
  • between _open_and_read_parquet_parallel and _open_stage_files_parallel, one is eager one is lazy

is it possible to improve reusability? There is a Python built-in functools.partial which might help.

I think the 4 (2x2) can be consolidated into one function without sacrificing too much readability, but would improve maintainability

def _open_stage_files_parallel(session, path, is_sproc, is_lazy) -> list[pl.Dafarame] | list

) -> "polars.LazyFrame":
... # pragma: no cover

@experimental(version="1.54.0")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
@experimental(version="1.54.0")
@experimental(version="1.55.0")

Comment on lines +1376 to +1400
@publicapi
@overload
def to_polars(
self,
*,
lazy: bool = False,
use_parquet: bool = False,
max_workers: Optional[int] = None,
statement_params: Optional[Dict[str, str]] = None,
_emit_ast: bool = False,
) -> "polars.DataFrame":
... # pragma: no cover

@publicapi
@overload
def to_polars(
self,
*,
lazy: bool = True,
use_parquet: bool = False,
max_workers: Optional[int] = None,
statement_params: Optional[Dict[str, str]] = None,
_emit_ast: bool = False,
) -> "polars.LazyFrame":
... # pragma: no cover

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I remember overload function requires the parameter value to be defined in the following format

lazy: Literal[True]
lazy: Literal[False]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants