GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — perf: convert large Arrow results to rows in slices by vuanhphung · Pull Request #1039 · databricks/databricks-sql-python

Conversation

@vuanhphung

@vuanhphung vuanhphung commented Nov 12, 2026 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Collaborator

What type of PR is this?

  • Other (performance)

Description

fetchall() and large fetchmany() calls convert the Arrow result with one to_pandas() call and one DataFrame.to_numpy(dtype=object) over the whole table, so a DataFrame, a full object array and the final Row list are all alive at the peak. On a 5M-row mixed-type result (INT/BIGINT with NULLs, DOUBLE, STRING, DECIMAL, DATE, TIMESTAMP, BOOLEAN, ARRAY) under Python 3.11, conversion peaked at about +5.3 GB RSS, while the returned rows alone hold about +3.9 GB.

This converts 100,000 rows per to_pandas() call. That is the default arraysize, so fetchone() and default-size fetchmany() take the same single-call path as before. Some columns are still converted once over the whole table, exactly as before, so every returned value and type is unchanged:

  • Nested columns. pyarrow picks the element dtype of a list or map from the whole column (one NULL element turns every ARRAY<BIGINT> value into a float64 array) and of a struct per Arrow chunk.
  • Decimal, duration and interval columns. Their Python values (Decimal on Python 3.13+, Timedelta, DateOffset) are tracked by the cyclic GC. Created slice by slice between batches of rows, they made the GC run 13 full collections instead of 5 for this result on Python 3.13/3.14, and the conversion was 7–15% slower than main there. Created first, as on main, they don't.

Null placeholder columns keep each partial DataFrame multi-block like the whole table's, because pandas < 2.2 returns durations from a single-block DataFrame as ints.

Rows are now also built with tuple.__new__(Row, values) and the usual __fields__ assignment, instead of three Python calls per row (Row.__call__ → _create_row → Row.__new__). The rows are the same objects; tests compare them against Row(*names)(*values). With more values than column names, it falls back to ResultRow(*values), which raises the same error.

Related open PRs: #982 and #1007 call _convert_arrow_table unchanged and merge cleanly. #979 (NaN vs NULL) and #960 (nested conversion) edit the same function body, so whichever lands second needs a small rebase: #979's NaN restore moves into to_object_array, and #960's nested conversion replaces the whole-column call. This PR doesn't change either behavior. #1043 is an optional follow-up stacked on this branch that pauses the GC while rows are built.

How is this tested?

  • Unit tests
  • E2E Tests
  • Manually

The unit tests cover:

  • sliced conversion matching whole-table conversion value by value and type by type (nested, struct, decimal, duration, interval, tz timestamps, duplicate column names);
  • bounded slices, and which columns are converted whole;
  • converted rows behaving like Row(*names)(*values): type, __fields__, equality, hash, repr, asDict(), attribute/index/key access, pickled bytes, copy, and the too-many-values error.

They pass on Python 3.9–3.14 with pandas 1.5/2.2/3.1 and pyarrow 14–26. A main-vs-branch script also compared every value and type on about 3,600 table layouts and slice sizes in each of those environments, and on live Thrift inline, CloudFetch and kernel results. All were identical.

The table below is for the 5M-row result above: _convert_arrow_table alone, a fresh process per run, main and this PR interleaved, median of 5. p95 is within 6% of the median. "+ later GC" adds a phase after the conversion that keeps allocating, so GC work deferred past the conversion is counted too.

Python conversion: main → this PR + later GC: main → this PR peak RSS: main → this PR
3.9 25.9 → 23.7 s 27.5 → 25.4 s 4.12 → 3.70 GB
3.10 26.2 → 24.1 s 27.8 → 25.7 s 4.07 → 3.65 GB
3.11 24.4 → 22.7 s 26.6 → 25.0 s 5.28 → 4.00 GB
3.12 26.5 → 24.5 s 28.3 → 26.3 s 5.21 → 4.80 GB
3.13 23.7 → 21.2 s 26.1 → 23.7 s 5.39 → 4.97 GB
3.14 21.3 → 19.6 s 23.5 → 21.9 s 6.37 → 4.93 GB

Before the last two commits, live fetchall() over Thrift CloudFetch for this query on Python 3.11 had a 5.99 → 4.45 GB peak RSS. _disable_pandas=True is unaffected.

This pull request and its description were written by Isaac.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@vuanhphung Isaac
perf: convert large Arrow results to rows in slices
60cf99d
fetchall() and large fetchmany() converted the whole Arrow table with one
to_pandas() call and one object ndarray, so a DataFrame, the object array
and the final Row list were all alive at the peak. Convert 100,000 rows at
a time instead. Columns whose pandas conversion depends on the column's
other values (list/map/struct: pyarrow picks the element dtype from the
whole column) are still converted over the whole table, so every value
and type is unchanged.

Signed-off-by: Vu Anh Phung <[email protected]>
Co-authored-by: Isaac <[email protected]>
@vuanhphung
vuanhphung had a problem deploying to azure-prod November 12, 2026 11:25 — with GitHub Actions Failure
@vuanhphung
vuanhphung temporarily deployed to azure-prod November 12, 2026 11:25 — with GitHub Actions Inactive
peco-review-bot[bot]
peco-review-bot Bot approved these changes Nov 12, 2026
View reviewed changes

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

There was a problem hiding this comment.

Choose a reason for hiding this comment

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

✅ No issues identified by the review bot.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@vuanhphung
vuanhphung temporarily deployed to azure-prod November 12, 2026 11:44 — with GitHub Actions Inactive
@vuanhphung vuanhphung mentioned this pull request Nov 12, 2026
perf: build result rows directly and pause the GC per batch (stacked on #1039) #1043
Open
4 tasks done
vuanhphung and others added 2 commits November 12, 2026 20:35
@vuanhphung Isaac
perf: build result rows without per-row Python calls
78ae634
Rows were built through Row.__call__ -> _create_row -> Row.__new__, three
Python calls per row. Build them with tuple.__new__ and the usual
__fields__ assignment instead, which gives the same Row objects (tested
against Row(*names)(*values)). With more values than column names it
falls back to ResultRow(*values), which raises the same error.

Signed-off-by: Vu Anh Phung <[email protected]>
Co-authored-by: Isaac <[email protected]>
@vuanhphung Isaac
perf: convert columns with GC-tracked values over the whole table
21e3b43
On Python 3.13+ Decimal values are tracked by the cyclic GC (as Timedelta
and DateOffset are on every version). The whole-table to_pandas() on main
created all of them before any row, so the GC's long-lived generation was
already large when rows were built and it ran 5 full collections for 5M
mixed rows. Created slice by slice between rows, they made it run 13,
which made the sliced conversion 7-15% slower than main on 3.13/3.14.
Convert decimal, duration and interval columns over the whole table like
nested ones; their values are unchanged. The whole-column DataFrame gets
a null placeholder column so it stays multi-block like the whole table's
(pandas < 2.2 returns a lone duration column as ints otherwise).

Signed-off-by: Vu Anh Phung <[email protected]>
Co-authored-by: Isaac <[email protected]>
@vuanhphung
vuanhphung requested a deployment to azure-prod November 12, 2026 21:46 — with GitHub Actions In progress
@vuanhphung
vuanhphung deployed to azure-prod November 12, 2026 21:46 — with GitHub Actions Active
@vuanhphung
vuanhphung deployed to azure-prod November 12, 2026 21:46 — with GitHub Actions Active
peco-review-bot[bot]
peco-review-bot Bot approved these changes Nov 12, 2026
View reviewed changes

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

There was a problem hiding this comment.

Choose a reason for hiding this comment

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

Verdict: 1 Low

Looks good, with one low-severity wording/performance note. The sliced conversion keeps nested, decimal, duration and interval columns in a single whole-table pass, as main does. Placeholder columns keep the same pandas block layout, and the _make_rows fast path builds the same objects as _create_row (__fields__ is the ResultRow itself). The tests compare each value and its type against the unsliced conversion.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread src/databricks/sql/result_set.py
append = rows.append
for v in values:
row = new_row(Row, v)
row.__fields__ = ResultRow

@peco-review-bot peco-review-bot Bot Nov 12, 2026

Copy link
Copy Markdown

There was a problem hiding this comment.

Choose a reason for hiding this comment

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

🔵 Low — row.__fields__ = ResultRow still calls the Python-level Row.__setattr__ once per row. That makes the CHANGELOG line "build the rows without per-row Python calls" (and the _make_rows docstring) slightly wrong: it went from three calls per row to one, not zero. You could either make the wording precise ("with one Python call per row instead of three") or, if you want the extra speed, write row.__dict__["__fields__"] = ResultRow. That does the same thing as Row.__setattr__ without the method call. _assert_same_as_factory_row already checks vars(row).keys(), so the existing tests would cover the change.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@peco-review-bot
peco-review-bot Bot deployed to azure-prod November 12, 2026 21:50 Active

This branch was successfully deployed

1 active deployment
azure-prod — 21e3b439 Deployed Nov 12, 2026 by peco-review-bot[bot] via followup #2150
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

@peco-review-bot peco-review-bot[bot] peco-review-bot[bot] approved these changes

Uh oh!

There was an error while loading. Please reload this page.

Assignees

No one assigned

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

Uh oh!

There was an error while loading. Please reload this page.

1 participant

@vuanhphung
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
You are visitor 01402503 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.