Skip to content

[BUGFIX] Exclude nulls and fix the rank offset in the SQLite quantile metric - #12008

Open
SreeramaYeshwanthGowd wants to merge 4 commits into
fivetran:developfrom
SreeramaYeshwanthGowd:BUGFIX/sqlite-quantile-null-handling
Open

[BUGFIX] Exclude nulls and fix the rank offset in the SQLite quantile metric#12008
SreeramaYeshwanthGowd wants to merge 4 commits into
fivetran:developfrom
SreeramaYeshwanthGowd:BUGFIX/sqlite-quantile-null-handling

Conversation

@SreeramaYeshwanthGowd

Copy link
Copy Markdown

ExpectColumnQuantileValuesToBeBetween is one rank low on SQLite and raises on columns with nulls.

Root cause

column_quantile_values.py:332 offsets by quantile * table_row_count - 1. That truncates to floor(q*n)-1 where percentile_disc gives ceil(q*n)-1, and nulls are not filtered. column_median.py:60-70 does this null-safely already.

Fix

Ranks now use column_values.nonnull.count and exact Fraction arithmetic. MySQL's CTE gets the same filter.

Tests

Three integration tests, two of which fail on develop.


  • Description of PR changes above includes a link to an existing GitHub issue
  • PR title is prefixed with one of: [BUGFIX], [FEATURE], [DOCS], [MAINTENANCE], [CONTRIB], [MINORBUMP]
  • Code is linted - run invoke lint (uses ruff format + ruff check)
  • Appropriate tests and docs have been updated
  • For any behavioral change to a data source, validation mechanic, or Expectation, at least one integration test exists in tests/integration/data_sources_and_expectations
  • CI is green, including linting, mypy type-checking, and tests - this is required for merge
  • If this PR proposes adopting or recommending a particular third-party library or service, any affiliation with it is disclosed above

… metric

The SQLite branch of column.quantile_values computed offsets as
quantile * table_row_count - 1, which is a float that SQLite truncates, so the
effective index was floor(q*n)-1 where percentile_disc is ceil(q*n)-1. Every
quantile whose q*n was not a whole number came back one rank low.

The query also had no null filter, and table_row_count counts nulls. SQLite
sorts nulls first, so on a column containing nulls the offsets pointed into
the null prefix and the metric returned None, which surfaced as a TypeError
when the result was range-checked.

Rank over the non-null values instead. The count comes from a
column_values.nonnull.count dependency, declared the way column_median.py
already declares it and requested only for SQLite, since no other dialect
reads it. The rank is computed with fractions.Fraction rather than in floating
point, because 0.56 * 25 is 14.000000000000002 and a plain math.ceil would
skip a rank.

Apply the same null filter to the MySQL percent_rank CTE, which had the same
omission. On null-free data it is a no-op.
@netlify

netlify Bot commented Jul 27, 2026

Copy link
Copy Markdown

👷 Deploy request for niobium-lead-7998 pending review.

Visit the deploys page to approve it

Name Link
🔨 Latest commit 6fe72f9

@github-actions github-actions Bot added the cla-not-signed https://github.com/fivetran/great_expectations/blob/develop/CLA.md label Jul 27, 2026
@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

All committers have signed the CLA. ✅

@SreeramaYeshwanthGowd SreeramaYeshwanthGowd changed the title [BUGFIX] Exclude nulls and fix the rank offset in the SQLite quantile… [BUGFIX] Exclude nulls and fix the rank offset in the SQLite quantile metric Jul 27, 2026
@SreeramaYeshwanthGowd

Copy link
Copy Markdown
Author

@cla-bot check

@github-actions github-actions Bot added cla-signed and removed cla-not-signed https://github.com/fivetran/great_expectations/blob/develop/CLA.md labels Jul 27, 2026
Reading the non-null count with metrics.get meant a missing dependency was
indistinguishable from an empty column, and the SQLite branch would return NaN
for every quantile instead of failing. Subscript instead, so the dependency
graph has to supply it.

Add a test for an all-null column, which previously raised a TypeError from
_validate on SQLite and now reports null observed values with success false,
matching the pandas backend.

Rename the rank test. Pandas uses nearest interpolation and does not select the
first rank reaching the quantile in general, it only agrees with percentile_disc
at the quantiles that test uses, so the old name claimed more than it checked.
@SreeramaYeshwanthGowd

SreeramaYeshwanthGowd commented Jul 27, 2026

Copy link
Copy Markdown
Author

Adding the detail I left out of the description, since this changes observed values on a supported backend and that should be on the record before review.

Reproduction on develop

import pandas as pd, sqlalchemy as sa
import great_expectations as gx
import great_expectations.expectations as gxe

pd.DataFrame({"amount": range(1, 16)}).to_sql(
    "tbl", sa.create_engine("sqlite:////tmp/q.db"), index=False
)
ctx = gx.get_context(mode="ephemeral")
ds = ctx.data_sources.add_sqlite("s", connection_string="sqlite:////tmp/q.db")
batch = ds.add_table_asset("a", table_name="tbl").add_batch_definition_whole_table("b").get_batch()

batch.validate(gxe.ExpectColumnQuantileValuesToBeBetween(
    column="amount",
    quantile_ranges={"quantiles": [0.25, 0.5, 0.75], "value_ranges": [[0, 99]] * 3},
)).to_json_dict()["result"]["observed_value"]

develop returns values: [3, 7, 11]. percentile_disc on the other SQL dialects gives
[4, 8, 12], which is what this branch returns.

Put nulls in the same column and develop returns observed_value: None with
raised_exception: true. The offsets land in the null prefix SQLite sorts first, and
expect_column_quantile_values_to_be_between.py:783 then evaluates range_[0] <= quantile_vals[idx]
against None:

TypeError: '<=' not supported between instances of 'int' and 'NoneType'

Behaviour change

This is not only a null fix. Null free SQLite columns also change wherever quantile * count is not a whole number. On [10, 20, 30, 40], q=0.6 was 20 and is now 30. Where the product is a whole number nothing moves, which is why test_success_complete_results passes unmodified.

An all null column also changes. It previously raised the TypeError above and now reports null observed values with success: false, which is what the pandas backend already does. test_all_null_column_reports_no_quantiles covers it.

I checked the one in tree consumer of the metric. column.partition requests column.quantile_values (column_partition.py:106-127), and the only caller is expect_column_kl_divergence_to_be_less_than.py:446 with bins="auto", which uses the quantiles to derive an IQR for Freedman-Diaconis bin counting rather than as bin edges. I ran that path on SQLite before and after and the observed partition is identical, so nothing downstream shifts.

Cost

SQLite now resolves column_values.nonnull.count, one extra aggregate per column domain, requested through the same _get_evaluation_dependencies hook column.median already uses (column_median.py:126-152). It is gated on the SQLite dialect so no other backend pays for it.

MySQL

The .where(column != None) added to the MySQL CTE is a no op on null free data. I have no MySQL locally, so it rests on test_nulls_are_excluded_from_quantiles in CI rather than on a local run. Separately, the MySQL path resolves quantiles from percent_rank, which picks the largest rank at or below the quantile rather than the first rank to reach it, so I expect it disagrees with percentile_disc too. That predates this change and I have not touched it, so the new rank tests run on pandas and SQLite only. Happy to file that separately.

CI

There is no signal on the change itself yet. check-actor-permissions fails at its Slack notify step because a fork PR cannot read the repo secrets, the permission step itself is skipped, and ci-required inherits the failure, so every code job reports as skipped.

@joshua-stauffer

Copy link
Copy Markdown
Collaborator

thanks for the PR @SreeramaYeshwanthGowd. I ran CI, and it looks like static analysis is failing. I'll give it a thorough review once CI is passing.

to_json_dict() returns a union type that mypy cannot narrow through two
chained string indices, which is why every other assertion in this module
compares the whole "result" dict instead. Match that pattern here too.
@SreeramaYeshwanthGowd

SreeramaYeshwanthGowd commented Aug 4, 2026

Copy link
Copy Markdown
Author

@joshua-stauffer. Thanks for triggering it. I've fixed it with new test assertion.

Ready for another run.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants