Skip to main content

upgrade-polars

Use when raising the supported Polars version range for cudf-polars (for example 2.0 to 2.1). Covers the version pins, adapting the translator to new IR/expression changes, the cudf-polars unit tests with 100% coverage, the upstream polars test suite (xfail/skip lists), and the narwhals plugins.

Source facts

Repository
NVIDIA/cudf
Last source activity
October 28, 2026 at 22:06
Detected SKILL.md language
English
Stars
9,784
Forks
1,140

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
upgrade-polars
description
Use when raising the supported Polars version range for cudf-polars (for example 2.0 to 2.1). Covers the version pins, adapting the translator to new IR/expression changes, the cudf-polars unit tests with 100% coverage, the upstream polars test suite (xfail/skip lists), and the narwhals plugins.
# Upgrading the supported Polars version in cudf-polars This skill describes how to move the `polars` range that cudf-polars supports (for example `>=2.0,<2.1` to `>=2.1,<2.2`). It is distilled from the previous upgrade PRs, weighted toward the Polars 2.0 upgrade (rapidsai/cudf#24549). A bump is done when all of these pass locally: 1. The cudf-polars unit tests, with **100% coverage** (CI uses `--cov-fail-under=100`). 2. The upstream polars test suite with the injected GPU engine (`in-memory` and `spmd`). 3. The narwhals run for cudf-polars (`narwhals_cudf_polars_test_plugin`, the second block of `ci/test_narwhals.sh`). The cudf and cudf.pandas runs do not exercise polars. They only matter if Narwhals itself is bumped, which the `update-narwhals-testing` skill covers (it requires all three to pass). 4. `pre-commit` (including mypy and `ci/check_cudf_polars_ir.py`). Do the main work against the **newest** polars you support: coverage and the upstream polars tests only run on it in CI. Then run the cudf-polars unit tests on the **oldest** supported polars too (Step 4). CI's wheel job (`wheel-tests-cudf-polars`, `POLARS_VERSIONS=earliest` in `ci/test_python_cudf_polars.sh`) does exactly that, and a test, xfail or assertion changed for the new version often breaks there. ## Prerequisites Ask the user for: - a working GPU (check `nvidia-smi`). Build the environment yourself in Step 2 the way CI does; only ask the user for one if that is not possible; - the target Polars version (default: the newest release, `pip index versions polars`); - the target Narwhals version, only if narwhals must change. Ground rules: - One Polars minor version per PR. Do not bundle unrelated fixes; split them out (a reviewer asked for this in #24549). - Never leave a test failing and never hide wrong results behind an xfail. Prefer making the translator raise `NotImplementedError` (CPU fallback) over an xfail when the GPU result would be wrong. - Small root-caused fixes go in the upgrade PR. Larger work gets a drafted issue, linked from the xfail/skip reason or a `TODO`. Draft issues for the user to review before opening them. - Record which tests were run and their outcome as you go; the PR description needs it. - On an existing PR branch, sync with GitHub first (see "Resuming work on an open PR"). Never force-push. - Install the target version into the dev environment first, then confirm with `python -c "import polars; print(polars.__version__)"`. If a test environment variable or plugin is missing, say so instead of silently skipping. ## Practical notes - Run long suites in the background with output to a log file, and check them later rather than waiting inline. - The narwhals pin lives in `dependencies.yaml` (`depends_on_narwhals`); read it there instead of assuming a version. - **CI under-reports failures.** Every test job stops at its first failure (`-x`, or xdist "stopping after N failures"), so a red PR shows only a handful of tests. In the Polars 2.1 upgrade CI showed 3 unit and 4 upstream failures per job; the full local runs had 37 unit failures and 153 (in-memory) / 65 (spmd) upstream failures. Treat CI as a smoke signal and triage from full local runs (`--maxfail=300`, `--maxfail=100000`). Read CI logs with `gh pr checks <pr>` and `gh run view --job <job-id> --log-failed`. - **Shared GPUs.** Check `nvidia-smi` before every run and never disturb other people's work. Use one GPU per process (`CUDA_VISIBLE_DEVICES`) and keep concurrent jobs on different GPUs. The streaming engine (and the xdist plugin runs) abort with `No GPUs found on current NUMA node N` when the process runs on CPUs of the other NUMA node. Find the GPU's node with `nvidia-smi topo -m` and pin: `numactl --cpunodebind=<node> --membind=<node> python -m pytest ...`. The `in-memory` engine is unaffected. - `pytest-cov` can under-report: lines such as `raise NotImplementedError(...)` that a test provably executes may show as missed in a full run. Before adding a pragma or a test, confirm a line is really unreachable by tracing it: run the query under `sys.settrace` (or with `pytest <that test file> --cov`) and see whether the line executes. Only lines that are not reached by any query on the new polars need a test or `# pragma: no cover`. - Upstream tests marked `write_disk` are deselected unless you pass `-m ""` (CI passes `-m "not slow"`); remember this when running a single upstream test. - The narwhals repo keeps its package under `src/narwhals`. To test the pinned version without installing it, put `<clone>/src` (not the clone root) first on `PYTHONPATH`, and print `narwhals.__file__` to confirm which copy is imported. - **Parallel work.** Independent failure clusters can be handed to subagents. Give each its own `git worktree` and branch off the current PR commit, its own GPU, a list of exact test ids, and the rule "fix and test on your branch, or recommend an xfail with a drafted issue". Agents must not edit shared dicts like `inject_gpu_engine.py`; you merge their commits with `git cherry-pick` and resolve the (usually append-only) conflicts. Give each agent a private scratch directory; shared scratch scripts get overwritten. ## Resuming work on an open PR (keep local and GitHub in sync) The branch on GitHub can change while you are away: the user squashes or rebases, force-pushes, accepts a review suggestion, edits the PR in the web UI, or a reviewer pushes. Your local checkout does not know until you fetch. Run this check before you edit, commit or push anything on an existing PR, and again right before every push. 1. Fetch and compare. `<branch>` is the PR branch: ```bash git fetch origin <branch> git status -sb git log --oneline HEAD..origin/<branch> # commits on GitHub that you do not have (remote ahead) git log --oneline origin/<branch>..HEAD # commits you have that GitHub does not (local ahead) ``` 2. Act on what you find: - **Both empty:** in sync; continue. - **Only the remote is ahead:** `git pull --ff-only origin <branch>`, then read what arrived (`git log -p ORIG_HEAD..HEAD`) before you build on it. - **Only you are ahead:** push normally, with no force. - **Both are non-empty (diverged):** find out whether history was rewritten. Test whether the remote still contains the last commit you pushed (`git merge-base --is-ancestor <last-pushed-sha> origin/<branch>`; `git merge-base HEAD origin/<branch>` should print that sha). - **It does** (the user merged `main`, added commits, or accepted a suggestion): nothing was rewritten. Rebase your unpushed commits onto the remote with `git rebase -S origin/<branch>` (`-S` keeps them signed), tell the user what arrived, and read the incoming diff for files you also touch. Check that your last verification still applies: a merge of `main` can change code your tests cover. - **It does not** (a squash, rebase or force-push): stop and tell the user. Do not merge, and do not force-push on your own. With the user's agreement: first keep a copy (`git branch backup/<branch>-$(date +%F)`), then move only your new commits onto the remote with `git cherry-pick` (your older commits are already inside the squash), resolve conflicts toward the remote side's intent, and check the result with `git diff origin/<branch>`. 3. Never `git push --force`. Use `--force-with-lease` only when the user explicitly asks for a history rewrite (for example to squash), because it refuses to overwrite a remote that moved since your last fetch. 4. Re-read the PR before changing anything on it: `gh pr view <n> --json headRefOid,body,reviews,comments,reviewDecision`, the inline review comments (`gh api repos/<org>/<repo>/pulls/<n>/comments`) and CI (`gh pr checks <n>`). The user may have edited the description, so merge your change into the current body; do not overwrite it from an old local file. The same applies to issues (`gh issue view` before `gh issue edit`). 5. After pushing, confirm the remote head is your commit (`git ls-remote origin <branch>`). Workflows on a draft PR do not start on their own: the user has to comment `/ok to test <sha>` for the new head. 6. After a squash by the user, your old commit hashes are no longer in the PR. Refer to commits by subject, and fix any note that quoted a hash. State in the PR which commit the last full test run was on, so later edits can be judged against it. 7. A branch can be checked out in only one worktree, so use the worktree that has it. Do not use bare `git stash` (the stash is shared across worktrees); commit instead. 8. When you address a review comment, say what you changed. Replying to or resolving threads is the user's call unless they ask you to. ## Step 1. Survey what changed upstream Start by shallow-cloning Polars at the target tag into a temporary directory and keep it for the whole upgrade. It is the reference for this survey, for the exact logic behind a behaviour change, and for the upstream test suite in Step 5: ```bash git clone --depth 1 --branch py-X.Y.Z https://github.com/pola-rs/polars.git <tmp>/polars git -C <tmp>/polars fetch --depth 1 origin tag py-OLD # only needed to diff against the old version git -C <tmp>/polars diff py-OLD py-X.Y.Z -- crates/polars-python/src/lazyframe/visitor py-polars/src/polars/_plr.pyi ``` Read the Polars release notes for every version between the old and new pin (`gh api repos/pola-rs/polars/releases/tags/py-X.Y.Z -q .body`; they are long and mostly irrelevant to cudf-polars, so skim for the items below) and, when a note is unclear, read the code in the clone (the visitor in `crates/polars-python`, optimizer rules in `crates/polars-plan`). List: removed or renamed APIs, new IR nodes or `Function`/`BooleanFunction`/`StringFunction`/`StructFunction`/ `TemporalFunction` enum members, changed option payloads, changed error types/messages, changed dtype semantics, and new deprecation warnings (the test suite runs with `filterwarnings = error`). Keep the list; it drives steps 3 to 5. Also read the Polars upgrade guide (https://docs.pola.rs/releases/upgrade/), which lists the breaking changes and deprecations for each release. Check each item against cudf-polars; behaviour changes that the GitHub release notes only mention briefly are often described properly there. Beyond release notes, the most reliable signals are the tests themselves: `test_from_polars_all_names` for enum drift, and the diffs of `crates/polars-python/src/lazyframe/visit.rs` and `py-polars/src/polars/_plr.pyi` between the two tags (in the clone) for IR changes. Also run `git log` and grep the tree for stale guards that a merge may have reintroduced: `grep -rn "POLARS_VERSION_LT\|POLARS_LOWER_BOUND\|polars_compat" python ci dependencies.yaml`. ## Step 2. Bump the pins All the places that carry the range (edit the exact same string everywhere): | File | What | | --- | --- | | `dependencies.yaml` | `run_cudf_polars` list: `polars>=X,<Y` | | `dependencies.yaml` | `test_cudf_polars_compat`: the `polars_compat_version` list under `files:` and the matching `polars==X.*` entries under `dependencies:` (one block per supported minor, plus the fallback entry). CI scripts read this matrix, so they usually do not need edits | | `python/cudf_polars/pyproject.toml` | **generated** from `dependencies.yaml` by `rapids-dependency-file-generator` (the list carries a "generated" marker); do not hand-edit, confirm it changed after the generator run | | `conda/recipes/cudf-polars/recipe.yaml` | `polars >=X,<Y` | | `.pre-commit-config.yaml` | mypy hook `additional_dependencies` | | `conda/environments/all_cuda-*_arch-*.yaml` | **generated** by the same hook; run `pre-commit run --all-files rapids-dependency-file-generator` rather than hand-editing | | `python/cudf_polars/cudf_polars/utils/versions.py` | `POLARS_LOWER_BOUND` only if the minimum moves | Default for a new minor: **raise the upper bound only** (`>=2.0,<2.1` becomes `>=2.0,<2.2`) and append the new minor to the `test_cudf_polars_compat` matrix (`["2.0", "2.1"]`, plus a `polars==2.1.*` block next to the existing one). Do not drop the previous minor in the same PR. Policy for the lower bound: support roughly the last 5 or 6 minor versions, but cloud defaults (Colab, AzureML, Kaggle) can justify a lower minimum. Confirm the intended range with the user before editing, and ask before raising the minimum. When the upgrade adds support for a new **major** Polars version (for example 2.0), the lower bound moves too: raise it to the first minor of that major, update `POLARS_LOWER_BOUND`, and remove the guards, code and tests that only supported the older major (see below). Regenerate the generated files (the conda environment files and the `pyproject.toml` dependency list) with the env's own `pre-commit` (it is usually not on `PATH`): `export PATH=<env>/bin:$PATH; pre-commit run --all-files rapids-dependency-file-generator`. The first run "fails" because it modified files; that is expected. Run it again to confirm it is clean. It needs network access to initialise the hooks. When the minimum moves, delete every guard for versions below it (and the code, tests, `request` fixtures and xfail conditions that only existed for them). Do **not** reintroduce `POLARS_VERSION_LT_*` flags speculatively. Add one to `versions.py` only when the code must behave differently on two supported versions, and delete it when the lower bound passes it. Parenthesize conditional expressions (`x == (A if cond else B)`); an unparenthesized one was a real bug in an earlier upgrade. ### Remove run-list entries for dropped versions PR CI selects upstream tests from `ci/cudf_polars/gpu_test_run_list.json`, which has one entry per supported Polars version (`<version>|spmd|default`). Regenerating the list keeps entries for old versions, so when the lower bound moves, remove the entries below it in the same PR. After the `pyproject.toml` regeneration above, run from the repo root: ```bash python .agents/skills/update-cudf-polars-polars-tests-selection/prune-run-list.py ``` The script reads the minimum supported version from the `polars` requirement in `python/cudf_polars/pyproject.toml`, prints each entry it removes, and rewrites the list in place. Check that `git diff ci/cudf_polars/gpu_test_run_list.json` only removes entries for versions below the new minimum. The newest supported version also needs an entry, or PR CI fails before running any tests. Generate it with the `update-cudf-polars-polars-tests-selection` skill once the upstream suite passes (Step 5). ### Build the environment the way CI does Do this right after the pin and the generator run, so the environment matches what CI will use: 1. Edit the polars pin in `dependencies.yaml` (the `run_cudf_polars` list and the `test_cudf_polars_compat` matrix, see the table). 2. Run `rapids-dependency-file-generator` (the pre-commit hook above) so the generated files pick up the change. 3. Create an environment from `dependencies.yaml` or from a generated file. CI builds it with `rapids-dependency-file-generator --output conda --file-key <key> --matrix "cuda=<ver>;arch=<arch>;py=<ver>;dependencies=<latest|oldest>"` followed by `mamba env create -f` (`ci/test_python_common.sh`); the cudf-polars job uses the keys `test_python_other` and `test_cudf_polars_compat` (`ci/test_python_cudf_polars.sh`, which also reads the polars version from the matrix). For a local environment, `conda env create -n <name> -f conda/environments/all_cuda-<ver>_arch-<arch>.yaml` is the same set of packages. If the new polars release is not on conda-forge yet, `pip install polars==X.Y.Z` into the environment and run `pip check`. 4. Build or install cudf-polars and the related libraries into it: `pip install -e python/cudf_polars`, with pylibcudf and libcudf from the nightly channel unless you are changing C++. Confirm `python -c "import polars, cudf_polars; print(polars.__version__)"`. To reproduce a failing CI job in its own container instead, use the `reproduce-ci` skill. ## Step 3. Adapt the translator and expressions Run the code against the new polars and fix in this order. 1. **IR version gate.** `Translator.translate_ir` in `cudf_polars/dsl/translate.py` rejects IR versions it has not been validated against. The error message prints the version of the polars you are *adopting* (for example `(16, 0)`), which is a version you must now support, **not** the value to put in the gate. Once the code works against it, set the gate to the next minor, `>= (16, 1)`. Keep the `# pragma: no cover` on the raise. 2. **Enum drift.** `tests/dsl/test_serialization.py::test_from_polars_all_names` compares polars' function name enums with ours exactly. For each new member add `Name.X = auto()` (alphabetical) in `dsl/expressions/{boolean,string,struct,temporal}.py`. If it is unsupported, ensure it raises `NotImplementedError` so it falls back to CPU, and add a translation-raises test. Track unimplemented new features in a drafted issue. 3. **Changed IR/option payloads.** When a node gains or reshapes a field (for example `MergeSorted.key` becoming a list, a new `HConcat` slot, `Sort.slice` becoming a 3-tuple), update the translate function and the IR class. When an IR class signature changes, grep every constructor call (translator, `streaming/`, tests). `ci/check_cudf_polars_ir.py` validates `do_evaluate` signatures. 4. **New fallbacks.** If a new option cannot be honoured on GPU, raise `NotImplementedError` from the translator (or the node constructor) with a clear message. Add `assert_ir_translation_raises(q, engine, NotImplementedError)` tests; where the *upstream* test cannot pass on GPU, this is the preferred fix. 5. **Semantic changes.** Fix when small (with a unit test). If it needs libcudf work or a redesign, fall back to CPU and draft an issue. 6. **Pass the stream.** Pass `stream=df.stream` to every pylibcudf call you add or touch (reviewers sweep for it). Prefer `Column.astype` over hand-rolled cast checks, and drop `hasattr` guards for APIs that now always exist. 7. **Imports.** Use `from polars import _plr as plrs` for the private Rust bindings. mypy needs the overrides already in the root `pyproject.toml` for `polars._plr`. 8. **Polars constant-folds literals into scans.** In 2.1 `select(pl.lit(<struct/enum>))` becomes a `DataFrameScan`, so the translator never sees a `Literal` and a test that expects `NotImplementedError` from that query stops raising. Keep the raise test but build the query with `with_columns` (or `when/then`), which still reaches the `Literal`, and add a small `assert_gpu_result_equal` test for the folded `select` form. Check what a changed test now exercises (print `translator.errors`), not just that it passes.
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub