Repository navigation
[BUG] Column pair and multicolumn Expectations pass rows containing NULL on SQL backends #12252
Description
Describe the bug
On SQL data sources, a column pair or multicolumn Expectation treats any row where the condition evaluates to NULL as passing. So a row with a NULL in one of the compared columns passes even when it clearly doesn't meet the expectation. The same data fails on pandas.
The unexpected condition is built as sa.not_(expected_condition) in column_pair_condition_partial.py:192 and multicolumn_condition_partial.py:190. When the condition is NULL (e.g. 1 + NULL + 100 = 7), NOT NULL is still NULL, and the unexpected count's CASE WHEN ... THEN 1 ELSE 0 counts the row as 0.
Affected (confirmed): ExpectMulticolumnSumToEqual, ExpectColumnPairValuesAToBeGreaterThanB (with or without or_equal), ExpectColumnPairValuesToBeInSet.
Expected: these rows are counted as unexpected, same as on pandas.
Great Expectations version
1.23.1
Python version
3.13.3
Execution engine / data source
SQLite
If "Other", name the execution engine or data source
No response
Minimal reproduction
- Load the same two-row table into a pandas asset and a SQLite asset.
- Validate the Expectation. One row passes, the other has a NULL and should fail.
- pandas reports 1 unexpected row, SQLite reports 0 and
success=True.
import tempfile
from pathlib import Path
import pandas as pd
import sqlalchemy as sa
import great_expectations as gx
import great_expectations.expectations as gxe
cases = {
"multicolumn_sum": (
pd.DataFrame({"a": [1, 1], "b": [6, None], "c": [0, 100]}),
gxe.ExpectMulticolumnSumToEqual(column_list=["a", "b", "c"], sum_total=7),
),
"pair_greater": (
pd.DataFrame({"a": [9.0, None], "b": [1.0, 5.0]}),
gxe.ExpectColumnPairValuesAToBeGreaterThanB(column_A="a", column_B="b"),
),
"pair_in_set": (
pd.DataFrame({"a": ["x", "x"], "b": ["y", None]}),
gxe.ExpectColumnPairValuesToBeInSet(column_A="a", column_B="b", value_pairs_set=[("x", "y")]),
),
}
context = gx.get_context(mode="ephemeral")
db = Path(tempfile.mkdtemp()) / "repro.db"
engine = sa.create_engine(f"sqlite:///{db}")
pandas_bd = context.data_sources.add_pandas("pd").add_dataframe_asset("df").add_batch_definition_whole_dataframe("all")
sqlite_ds = context.data_sources.add_sqlite("sqlite", connection_string=f"sqlite:///{db}")
for name, (df, expectation) in cases.items():
df.to_sql(name, engine, index=False)
sql_batch = sqlite_ds.add_table_asset(name, table_name=name).add_batch_definition_whole_table("all").get_batch()
pd_batch = pandas_bd.get_batch(batch_parameters={"dataframe": df})
p, s = pd_batch.validate(expectation), sql_batch.validate(expectation)
print(f"{name}: pandas success={p.success} unexpected={p.result['unexpected_count']} | "
f"sqlite success={s.success} unexpected={s.result['unexpected_count']}")# sample data (inline above)
multicolumn_sum: a=[1, 1] b=[6, NULL] c=[0, 100] sum_total=7
pair_greater: a=[9.0, NULL] b=[1.0, 5.0]
pair_in_set: a=["x", "x"] b=["y", NULL] value_pairs_set=[("x", "y")]
# output (no exception, SQLite result is wrong)
multicolumn_sum: pandas success=False unexpected=1 | sqlite success=True unexpected=0
pair_greater: pandas success=False unexpected=1 | sqlite success=True unexpected=0
pair_in_set: pandas success=False unexpected=1 | sqlite success=True unexpected=0
Additional environment details
Windows 11, pandas 3.0.6, SQLAlchemy 2.0.54. Also reproduces on develop (ca1c660).
Additional context
Only tested on SQLite, but the condition is built in dialect-independent code so other SQL backends are probably affected too. The existing integration tests for these Expectations don't include rows with a partial NULL.
Activity
adimalkar commentedon Sep 23, 2026
Thanks for the clear report — the file and line pointers made this quick to confirm. I reproduced it on
develop(ca1c660c6) on SQLite and on PostgreSQL 18, and opened #12255.The fix is in the three metrics rather than the two partials. I tried the partials first, as a NULL-safe negation: it fixes these three Expectations, but the same partials serve
ExpectSelectColumnValuesToBeUniqueWithinRecord, where pandas lets a NULL row pass, and that change made it disagree. So each metric now requires its compared columns to be present — the patternExpectColumnPairValuesToBeEqualalready uses — and aNoneinvalue_pairs_setstill matches a NULL row.Comparing pandas and SQL across all 27 pair/multicolumn cases, disagreements go from 12 to 3 on both databases, and no case that agreed before changes. The PR lists the 3 that remain and why they're separate:
ExpectCompoundColumnsToBeUniqueworks a different way, and there's a deliberate both-NULL rule foror_equal. It also notes that Spark likely has the same issue in two of these metrics — I couldn't run Spark here, so I left it alone.(This change was developed with AI assistance.)
joshua-stauffer commentedon Sep 29, 2026
thanks for reporting this bug @rachithga! and thanks for the PR @adimalkar - I'll assign this issue to you, and review shortly.
joshua-stauffer commentedon Sep 29, 2026
One more Expectation has the same problem:
ExpectCompoundColumnsToBeUnique. On SQL, a duplicated key that has a NULL in one of its columns isn't counted as unexpected. I reproduced it ondevelop(8d89627f5) against SQLite, and it still fails with #12255 applied.Reproduction
Add to
tests/integration/data_sources_and_expectations/expectations/test_expect_compound_column_values_to_be_unique.py:Observed:
assert 2 == 4(the two(None, 5)rows are not counted)Expected:
unexpected_count == 4, which is what pandas reports for the same data.Requirements
ExpectMulticolumnSumToEqual,ExpectColumnPairValuesAToBeGreaterThanB, andExpectColumnPairValuesToBeInSetmust count it as unexpected on SQL backends, matching pandas under the defaultignore_row_if.ExpectCompoundColumnsToBeUnique, rows whose key includes a NULL must count as duplicates of each other when the non-NULL values match, as pandas does.ignore_row_if(all values missing, or any value missing withany_value_is_missing) must stay excluded.Out of scope: Spark, which uses separate code for these metrics and wasn't checked here.
Context
group_count_query.c[name] == original_table_clause.c[name](compound_columns_unique.py:144).NULL = NULLnever matches, so a duplicated key with a NULL in it drops out of the count.compound_columns_unique.py:105).adimalkar commentedon Sep 29, 2026
Thanks for the reproduction. I've pushed the fix to #12255:
ExpectCompoundColumnsToBeUniquenow counts each key withCOUNT(*) OVER (PARTITION BY ...)on every dialect, the query the MySQL branch already used.PARTITION BYgroups NULLs together, so your test now gives 4 on SQLite and PostgreSQL.The new integration test also covers requirement 3: with two
(None, 5)and two(None, None)rows it expects 4, 2 and 6 unexpected underall_values_are_missing,any_value_is_missingandnever, and it matches pandas on each. I re-ran the pandas-vs-SQL comparison across every pair and multicolumn Expectation. Differences drop from 11 to 1 on both SQLite and PostgreSQL, with none newly introduced. The remaining one is the both-NULLor_equal=Truecase described in the PR. I couldn't run SQL Server or ClickHouse locally.dbsgh3344 commentedon Oct 1, 2026
Hi! I noticed the PR description mentions the Spark side was left untouched due to no local Spark environment.
I've reproduced the same NULL-handling inconsistency on Spark locally and have a fix ready
Would it be okay if I opened a follow-up PR for the Spark fix? Happy to rebase on top of this one once it merges.
github-actions commentedon Oct 4, 2026
⏰ Assignment Reminder
Hi @adimalkar, this is a friendly reminder about your assignment to this issue.
Warning
This issue will be automatically unassigned in 4 days if there's no activity.
How to keep your assignment
You can prevent automatic unassignment by:
We appreciate your contribution and are here to help if needed!
adimalkar commentedon Oct 4, 2026
@joshua-stauffer I'm still maintaining this fix. I've updated #12255 with the latest
develop(7d52570c9); the merge was conflict-free and preserves the NULL-handling fixes, including the compound-key case you requested.After the update, local validation passed:
invoke lint,invoke fmt --check, andinvoke type-check --ci --pretty(971 source files)Could you rerun the full CI with maintainer permissions and review the PR when available? The external-contributor
check-actor-permissionsgate prevents the upstream test jobs from running.