Skip to content

SNOW-3923354 decouple pandas version from python connector and support pandas 3.x - #4312

Open
sfc-gh-jzeng wants to merge 20 commits into
mainfrom
jzeng/snow-3923354-pandas3x-support
Open

SNOW-3923354 decouple pandas version from python connector and support pandas 3.x#4312
sfc-gh-jzeng wants to merge 20 commits into
mainfrom
jzeng/snow-3923354-pandas3x-support

Conversation

@sfc-gh-jzeng

@sfc-gh-jzeng sfc-gh-jzeng commented Aug 11, 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-3923354

  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.

    The connector [pandas] extra still pins pandas<3. This PR makes Snowpark's [pandas] extra declare pandas<4 and pyarrow itself, so a client can install pandas 3. pandas 2 stays supported. A Snowpark bump does not upgrade an existing pandas 2 install. [modin] stays on pandas 2 (pandas<=2.4).

    Customer BCR on pandas 3: to_pandas() / to_pandas_batches() use the str dtype for text columns (VARCHAR, VARIANT, OBJECT, ARRAY, MAP, geo). SQL NULL in those columns comes back as nan, so value is None misses. Use pandas.isna. collect() is unchanged. future.infer_string = False does not turn this off.

    write_pandas / write_arrow / create_dataframe convert duration columns to ns before write. Snowflake stores a unit-less integer; the contract is ns. pandas 3 defaults to us.

    Local testing rebuilds columns as Python lists (dtype=object) so SQL NULL stays None under pandas 3 str + Copy-on-Write. Precommit local-testing has two jobs: pandas 2 and pandas 3.

@github-actions

Copy link
Copy Markdown


Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.


I have read the CLA Document and I hereby sign the CLA


You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

@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

This PR was classified as LOW risk by the automated pre-screen.

@codecov-commenter

codecov-commenter commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.88%. Comparing base (846856f) to head (cf9a8eb).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4312      +/-   ##
==========================================
- Coverage   95.47%   94.88%   -0.59%     
==========================================
  Files         171      171              
  Lines       44749    44773      +24     
  Branches     7682     7687       +5     
==========================================
- Hits        42723    42484     -239     
- Misses       1253     1432     +179     
- Partials      773      857      +84     

☔ 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.

@github-actions github-actions Bot added the local testing Local Testing issues/PRs label Aug 14, 2026
snowflake-security-bot[bot]

This comment was marked as outdated.

snowflake-security-bot[bot]

This comment was marked as outdated.

snowflake-security-bot[bot]

This comment was marked as 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

This PR was classified as LOW risk by the automated pre-screen.

@sfc-gh-jzeng
sfc-gh-jzeng force-pushed the jzeng/snow-3923354-pandas3x-support branch from 8caf817 to 166930f Compare August 18, 2026 01:52
…as 3

ColumnEmulator replaced pandas.Series._metadata (['_name']) instead of
extending it. NDFrame.__finalize__ propagates only the intersection of the
two _metadata lists, so every ColumnEmulator copy silently dropped _name and
.name became None.

The defect is pre-existing and version-independent -- it reproduces on pandas
2.3.1 in isolation. What changed is that pandas 3.0.5 added
"grouper = grouper.copy(deep=False)" to Grouping.__init__, which forces that
copy on every group-by, so the pivot and group-by family started failing.
Upstream: pandas-dev/pandas#61491.

Beyond pivot, a nulled .name silently corrupts mock_count_distinct
(_functions.py:444 keys a TableEmulator on cols[i].name, collapsing every
column onto a single None key with no exception) and _functions.py:962. No
test covers either, which is why this is a product bug and not a pivot quirk.

TableEmulator._metadata is deliberately left alone: pandas.DataFrame._metadata
is empty, so it omits nothing, and adding "_name" there makes the
DataFrame->Series __finalize__ intersection non-empty and clobbers correct
column labels.

tests/integ/scala --local_testing_mode on pandas 3.0.5: 22 failed/595 passed
-> 9 failed/608 passed, zero regressions. tests/mock unchanged at 2/462.
pandas 2.3.1 stays fully green at 617 passed.
DataFrame.sort_values re-wraps each sort column as pandas.Series(ndarray)
before handing it to key= (pandas/core/frame.py; the line is identical in
pandas 2 and 3). What changed is Series.__init__ inference: pandas 3 infers
the dedicated str dtype for an object array of strings, and str's NA sentinel
is nan. So a SQL NULL that is a real None in the frame reached
custom_comparator as nan, `value_a is None` stopped firing, and the comparator
fell through to a mixed float/str comparison.
pandas 3 infers the dedicated str dtype for an object array of strings and None, and str's NA sentinel is nan—so seven mock-layer sites that deliberately produced None had it silently converted on the way out of apply, combine, DataFrame.T.apply, iterrows, and a bare ColumnEmulator built from a list. Rebuild each result at explicit object dtype; gate the variant sink on isna_helper, since iterrows re-infers per row and has no upstream dtype to preserve.
A chained inplace replace mutates a temporary, which pandas 2 still wrote through to the parent while warning. pandas 3 is Copy-on-Write only, so the write is discarded and only announced via ChainedAssignmentError—a Warning subclass, so nothing raises. MERGE-INSERT columns omitted from the insert clause kept nan instead of None. Assign the result back.
…module__

pandas 3 re-homed read_sql from pandas.io.sql to the top-level pandas namespace, and code_generation routes "from X import Y" by Y.module, so the generated source legitimately changed. Product code is correct; only the expectation was stale. Interpolate the module rather than hard-coding the pandas-3 spelling, which would move the failure to the py310 job that still resolves pandas 2.
pandas 3 removed the integer-as-position fallback on Series.getitem, so iloc[0][0], dtypes[0] and row[0] from iterrows became label lookups that raise KeyError. Test-only: the vectorized UDFs run fine server-side, and product code's row[0] indexes a Snowpark Row, which is a tuple subclass. The accessor fix alone is not sufficient -- it unmasks two expectations that were only hidden because the KeyError fired first. VARCHAR-transported types (STRING, ARRAY, GEOGRAPHY, GEOMETRY, MAP) now arrive as str dtype rather than object, and pandas.Timestamp.module moved to the top-level namespace. Both accept either value, matching the spelling test_pandas_udf_input_types already uses, so the py310 job on pandas 2 keeps passing.
Pandas 3 renames two dtypes this test pins by string: uncast VARCHAR comes back as str instead of object, and the local-testing timestamp default resolution moved from ns to us. Labels only; values and instants are unchanged, so accept either spelling rather than branching on version. The live path already gets its expected timestamp dtype from pyarrow, so only local testing was hit. Verified on pandas 2.3.1 and 3.0.5, offline and against a live account.
write_pandas serializes to parquet, where a timedelta64 column becomes a duration Snowflake does not read back as a duration. The raw tick count lands in a NUMBER, so the unit is baked into the stored value. Pandas 2 inferred ns; pandas 3 infers us. The same timedelta(days=1) therefore started storing 86400000000 instead of 86400000000000 into the same column, with no warning. Nanoseconds is the contract the local testing emulator already enforces via Timedelta.value, and what every table written by an older client holds. The live path only matched that by accident, because ns was pandas' default. Normalizing at the single connector call site covers both write_pandas and create_dataframe(pdf).

The to_pandas expectations go the other direction. The live path pins TIMESTAMP_NTZ to datetime64[ns] via pyarrow, while the emulator returns pandas' own default, so they cannot share a literal; the expectation is derived from local_testing_mode. String columns drop dtype=object, since plain inference now matches on both versions and both paths. Only the all-NULL column still needs an explicit dtype, because pd.Series([None]) still infers object on pandas 3.
MAP(STRING, INT) arrives as Arrow map<string, decimal128(38,0)>, so to_pandas() yields an object column of Decimal map values. Pandas 2 serializes that Decimal as a JSON number; pandas 3 emits a JSON string. Only one token changes: 1.0 becomes "1".

Despite the test name, this is not a dtype change. The column is object on both versions, verified directly, so the dtype half of the expectation is left alone, as is the non-structured branch, whose values travel as VARIANT text and are byte-identical across versions.

We never call DataFrame.to_json in product code; this only affects users who do.
Add `pandas_major_version` to `_internal/utils.py`
Split the unpinned local-testing job so both majors gate the PR.
write_arrow skipped the pandas write path, so a us duration was
stored 1000x too small. Also stop calling the local-testing NULL
fix pandas-3-only in the changelog.
These three still used Series.combine after the to_char fix, so a
string NULL became nan and IS NULL missed the row.
Name the remaining semi-structured and geo string types, and stop
saying the str/nan conversion cannot be disabled.
The helper-only Arrow test stays green if write_arrow skips normalize.
Pin both write paths and the mock initcap(delimiters) branch.
The timedelta fix applies to any non-nanosecond column, so scoping the
entry to pandas 3 told the affected pandas 2 users they were unaffected,
and omitted that rows written earlier need correcting. The pandas cap for
modin is declared by our own [modin] extra, not by modin itself.
The stored procedure suite uploads our test files to the server but runs
them against the snowpark bundled in the Python UDF sandbox, which ships
the released version rather than the branch build. A test module that
imports a symbol added on this branch therefore cannot be collected at
all, losing the whole file instead of skipping one assertion:

  ImportError: cannot import name 'pandas_major_version' from
  'snowflake.snowpark._internal.utils'

Jenkins trigger #103 caught this in PythonStoredProcBuildSnowfortTest.
GitHub CI cannot, because there the installed snowpark is the branch
itself and the import resolves. test_df_to_pandas.py carried the same
import and was a latent second instance, masked by SNOW-3674599 skipping
it for lack of pandas on Python 3.14.

The constant in _internal/utils.py stays for product code, which always
runs against its own tree.
@sfc-gh-jzeng
sfc-gh-jzeng force-pushed the jzeng/snow-3923354-pandas3x-support branch from 166930f to cf9a8eb Compare August 23, 2026 03:17
@sfc-gh-jzeng sfc-gh-jzeng changed the title SNOW-3923354 decouple pandas version from python connector and import pandas 3.x SNOW-3923354 decouple pandas version from python connector and support pandas 3.x Aug 23, 2026

@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

This PR was classified as LOW risk by the automated pre-screen.

@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

This PR was classified as LOW risk by the automated pre-screen.

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

Labels

local testing Local Testing issues/PRs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants