Skip to content

PyArrow: NotEqualTo push-down drops null rows (draft, see description) - #3918

Draft
jackylee-ch wants to merge 1 commit into
apache:mainfrom
jackylee-ch:pyarrow-not-equal-null
Draft

PyArrow: NotEqualTo push-down drops null rows (draft, see description)#3918
jackylee-ch wants to merge 1 commit into
apache:mainfrom
jackylee-ch:pyarrow-not-equal-null

Conversation

@jackylee-ch

@jackylee-ch jackylee-ch commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Rationale for this change

Draft: the change below is not the right fix, and the table explains why. Opening it as a draft to record the finding.

Pushing NotEqualTo down to Arrow drops rows where the column is null, while _ExpressionEvaluationVisitor.visit_not_equal returns None != value and _StrictMetricsEvaluationVisitor reports ROWS_MUST_MATCH for a column proven to hold only nulls. Evaluator.notEq in the reference implementation is !eq, and eq uses a nulls-first comparator, so it agrees with the evaluators.

Making the push-down agree with them is not enough. _expression_to_complementary_pyarrow ORs is_null back into the preserve filter for every term _NullNaNUnmentionedTermsCollector treats as null-unmentioned, NotEqualTo among them, so a scan starts matching null rows while a delete with the same filter still keeps them:

filter Arrow path scan delete removes agree on main with this change
NOT IN (a, b) ~isin [null, c] [c] no no
NOT IN (a) != [b, c] [b, c] yes no
!= a != [b, c] [b, c] yes no

The layer is already inconsistent, but in visit_not_in, and this change moves two agreeing cases into the disagreeing column rather than fixing the one that is broken. Completing it means delete/overwrite would start removing null rows for a != filter. That is a semantics change to a destructive operation, and it needs a decision on which convention to keep: the three-valued logic the Arrow layer follows today, or null-satisfies-negation as in the evaluators.

Are these changes tested?

Only the scan path, which is why this is a draft.

Are there any user-facing changes?

None proposed yet.

An Arrow comparison yields null for a null input, so `field != value` dropped
every row where the column is null. All the other evaluators treat a null as
satisfying NotEqualTo: `_ExpressionEvaluationVisitor.visit_not_equal` returns
`None != value`, `_StrictMetricsEvaluationVisitor` reports ROWS_MUST_MATCH for
a column proven to hold only nulls, and the Arrow NOT IN path already keeps
them because `~isin(...)` is false for a null.

The reference implementation agrees: `Evaluator.notEq` is `!eq`, and `eq`
compares with a nulls-first comparator, so a null never equals the literal.

Keep the null explicitly. NOT IN needs no change.

Co-Authored-By: Claude Code <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The new NotEqualTo null-preserving semantics likely require coordinated updates to _expression_to_complementary_pyarrow (via _NullNaNUnmentionedTermsCollector) to avoid incorrectly preserving null rows during rewrite-based deletes/overwrites using != filters.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR fixes a semantic mismatch in the PyArrow pushdown layer (pyiceberg/io/pyarrow.py) so that NotEqualTo (!=) retains rows where the compared column is null, aligning PyArrow filtering with PyIceberg’s in-memory and metrics evaluators.

Changes:

  • Update the PyArrow expression generated for BoundNotEqualTo to explicitly include null rows (is_null(col) OR (col != literal)).
  • Adjust the expected string representation for the generated PyArrow NotEqualTo expression.
  • Add a regression test asserting pushed-down Arrow filtering matches the in-memory evaluator for != with nulls.
File summaries
File Description
pyiceberg/io/pyarrow.py Changes NotEqualTo pushdown to preserve null rows by explicitly OR-ing with is_null.
tests/io/test_pyarrow.py Updates the NotEqualTo repr expectation and adds a regression test to ensure nulls are kept.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread pyiceberg/io/pyarrow.py
Comment on lines 921 to +925
def visit_not_equal(self, term: BoundTerm, literal: Literal[Any]) -> pc.Expression:
return pc.field(self._get_field_name(term)) != _convert_scalar(literal.value, term.ref().field.field_type)
# A null is not equal to the literal, but an Arrow comparison yields null for it and
# the row would be dropped. Keep it explicitly to match the other evaluators.
ref = pc.field(self._get_field_name(term))
return ref.is_null(nan_is_null=False) | (ref != _convert_scalar(literal.value, term.ref().field.field_type))
@jackylee-ch
jackylee-ch marked this pull request as draft September 7, 2026 09:16
@jackylee-ch jackylee-ch changed the title fix(io): keep nulls when pushing NotEqualTo down to Arrow PyArrow: NotEqualTo push-down drops null rows (draft, see description) Sep 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants