Skip to content

UNWIND [null] may produce a row whose value prints as null, but count(x) still counts it as 1. #2383

Closed

Description

@Silence6666668

Describe the bug
UNWIND [null] may produce a row whose value prints as null, but count(x) still counts it as 1.

In Cypher, count(expr) should count only non-null values. After UNWIND [null] AS x, the single row has x = null, so count(x) should be 0.

Instead, Apache AGE returns 1.

How are you accessing AGE (Command line, driver, etc.)?

  • PostgreSQL cypher(...) wrapper through the local Python differential-testing harness
  • Reproducible directly in psql inside the Docker container

What data setup do we need to do?
No graph data is required beyond creating an empty graph:

SELECT create_graph('fuzz_graph');

What is the necessary configuration info needed?

  • Plain Apache AGE Docker image was enough
  • Docker image in local repro: apache/age
  • AGE extension version: 1.7.0
  • PostgreSQL version: 18.1
  • Graph name used in repro: fuzz_graph
  • No extra extensions or special configuration were required

What is the command that caused the error?

SELECT * FROM cypher('fuzz_graph', $$
  UNWIND [null] AS x
  RETURN count(x) AS c
$$) AS (c agtype);

Returned result on AGE:

c
---
1

Expected behavior
The result should be:

c
---
0

Neo4j returns 0 for the equivalent Cypher query:

UNWIND [null] AS x
RETURN count(x) AS c

Environment (please complete the following information):

  • Version: Apache AGE 1.7.0
  • PostgreSQL: 18.1
  • Host OS: Windows
  • Architecture: x86_64
  • Deployment: Docker

Additional context
This appears to be a counting/null-semantics issue specifically around UNWIND, not a general count(null) problem.

Direct control cases on the same AGE instance:

  1. Plain count(null) behaves correctly:
SELECT * FROM cypher('fuzz_graph', $$
  RETURN count(null) AS c
$$) AS (c agtype);

Observed result:

0
  1. WITH null AS x RETURN count(x) also behaves correctly:
SELECT * FROM cypher('fuzz_graph', $$
  WITH null AS x
  RETURN count(x) AS c
$$) AS (c agtype);

Observed result:

0
  1. UNWIND [null] AS x RETURN x shows that the produced row is indeed null:
SELECT * FROM cypher('fuzz_graph', $$
  UNWIND [null] AS x
  RETURN x
$$) AS (x agtype);

Observed result:

null

A larger differential-testing case first exposed this via:

MATCH (p:Person)
OPTIONAL MATCH path = (p)-[:KNOWS]->(friend:Person)
WHERE p.age > 25
UNWIND [path] AS pth
RETURN p.name AS person_name, count(pth) AS friend_count

For rows where path was null, AGE returned friend_count = 1 instead of 0. Reducing the case to UNWIND [null] reproduced the same incorrect counting behavior.

Activity

  1. crprashant commented on Apr 22, 2026

    @crprashant
    Contributor

    Hi! I'd like to work on this. Could a maintainer please assign it to me?

    Initial hypothesis: UNWIND may be emitting an agtype null value rather than a true SQL NULL, so the null-check inside the count() aggregate doesn't trigger. The reporter's control cases confirm WITH null AS x RETURN count(x) works correctly, which suggests the divergence is specifically in UNWIND's row materialization path. I'll reproduce locally, trace the executor output, compare against the WITH path, and follow up with a fix proposal plus a regression test.

  2. gregfelice commented on Jul 3, 2026

    @gregfelice
    Contributor

    @jrgemignani — we have two open PRs fixing this bug via opposite approaches, and they conflict, so whichever merges first forces a rework of the other. Flagging for a maintainer call on direction before either author spends more effort (e.g. #2406 currently needs a rebase I've just asked for):

    #2411 (@crprashant) — narrow. Adds a separate age_unwind SRF and repoints only transform_cypher_unwind to it; age_unnest is untouched. Low blast radius, no risk to list comprehensions / predicate functions. Deliberately fixes only this issue (#2383) — it leaves the sibling list-comprehension null bug (#2393) and the single() / predicate three-valued-logic quirk unfixed, since those callers still rely on agtype_null > agtype_integer being truthy. (Currently conflicting + no green CI run.)

    #2406 (@MuhammadTahaNaveed) — root-cause. Fixes age_unnest in place (emit SQL NULL for AGTV_NULL) and rewrites the predicate-function 3VL, so it fixes #2383 + #2393 + single() together and makes null > 0 yield null per openCypher instead of truthy. Larger surface, but I've reviewed it and the approach is sound; it needs a rebase + two small tidy-ups (INT8PASSBYVAL, a comment).

    They're mutually redundant on the age_unnest/UNWIND path — we should merge one and close the other.

    My lean is #2406 as the more correct end state (fixes the whole null-handling family, not just the UNWIND symptom), with #2411 as the lower-risk fallback if we'd rather ship the narrow fix now and defer the 3VL rework. Your call on the direction — I'll shepherd whichever you pick to green + approval.

  3. added a commit that references this issue on Jul 3, 2026
    39273ca
  4. added a commit that references this issue on Jul 3, 2026
    e5431fc
  5. added a commit that references this issue on Sep 18, 2026
    892b466
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions