Describe the bug
In a multi-part query, all writes can be silently discarded — not merely invisible to later clauses, but never executed at all. The statement succeeds, returns a plausible result, and leaves the database unchanged.
AGE's DML executes inside a CustomScan buried in the plan tree rather than as a top-level ModifyTable. The planner is therefore free to place that subtree on either side of a join, and several executor nodes are entitled to skip an input entirely when the other input turns out to be empty. When the skipped input is the one containing the Cypher Create node, the writes never happen.
The empty input is typically the scan of the very label the query is writing to — which is empty precisely because the node that would populate it is the side being skipped. Chicken-and-egg, and completely silent.
Split out of #2491, which hits this as well as the separate visibility defect tracked in #2493. This issue is only the discarded-writes half.
How are you accessing AGE (Command line, driver, etc.)?
Command line, psql.
What data setup do we need to do?
None beyond an empty graph.
LOAD 'age';
SET search_path = ag_catalog, public;
SELECT create_graph('g');
What is the necessary configuration info needed?
None. Default configuration, no extra extensions.
What is the command that caused the error?
SELECT * FROM cypher('g', $cypher$
UNWIND [1,2] AS i CREATE (:N)-[:R {id:i}]->(:N)
WITH count(*) AS ig
MATCH ()-[r:R]->() RETURN count(r)
$cypher$) AS (v agtype);
No error is raised. Afterwards the graph is empty — zero nodes and zero edges:
SELECT * FROM cypher('g', $cypher$ MATCH (n) RETURN count(n) $cypher$) AS (c agtype);
c
---
0
EXPLAIN (ANALYZE) shows the writes were never executed:
Aggregate (actual rows=1.00 loops=1)
-> Merge Join (actual rows=0.00 loops=1)
-> Merge Append (actual rows=0.00 loops=1)
-> Index Only Scan using _ag_label_vertex_pkey ... (actual rows=0.00 loops=1)
-> Index Only Scan using "N_pkey" on "N" ... (actual rows=0.00 loops=1)
-> Materialize (never executed)
-> Nested Loop (never executed)
-> Index Scan using "R_end_id_idx" on "R" r (never executed)
-> ...
-> Custom Scan (Cypher Create) (never executed)
The merge join exhausts its left input, concludes the join can produce nothing, and never pulls the right input — which is where the CREATE lives.
It is not specific to merge joins. The query as filed in #2491 reaches the same outcome through a hash join, where an empty build side lets PostgreSQL skip reading the outer relation entirely:
Hash Join (actual rows=0.00 loops=1)
-> Hash Join (never executed)
...
-> Custom Scan (Cypher Create) (never executed) <- the nodes
...
-> Custom Scan (Cypher Create) (never executed) <- the edges
-> Hash (actual rows=0.00 loops=1)
-> Seq Scan on "N" _age_default_alias_6 (actual rows=0.00 loops=1)
Two different join strategies, same failure — so this is not one unlucky plan shape.
What decides whether it fires
Plan shape, not input-row count. Measured on master @ 8014174:
| Variant |
Writes persisted? |
Empty graph; later MATCH scans the label just written; 2 input rows |
❌ discarded |
Same, 1 input row (CREATE (:N)-[:R {id:1}]->(:N)) |
❌ discarded |
Same, non-aggregating WITH i AS keep instead of WITH count(*) |
❌ discarded |
Same query, but the graph already contains one :R edge |
✅ persisted |
| #2491 as filed (three stages, fresh graph) |
❌ discarded |
Later MATCH scans a different empty label than the one written |
✅ persisted |
#2490 as filed (single CREATE, later MATCH with WHERE r.id = item.id) |
✅ persisted |
The failing cases share one property: the scan of the label being written back has no visible rows at execution time. Pre-existing rows in that table are enough to make the same query behave.
Expected behavior
A write clause must execute for every input row it receives, regardless of the join strategy chosen for clauses that read afterwards. Query results may be debatable under AGE's current visibility rules (see #2493), but a CREATE that the user asked for must never be silently skipped.
Environment (please complete the following information):
- Version:
master @ 8014174 (1.8.0)
- PostgreSQL: 18.4 (Debian), built from source
- OS: Debian, Linux 6.12
Also reproduces on 1.7.0 — the original report in #2491 was against the 1.7.0 Docker image.
Additional context
Severity is worth noting: #2493 produces wrong answers, which is bad but visible. This one silently drops committed-looking work with no error, so an application has no way to detect that its writes did not happen short of reading back.
Fixing it likely means preventing the planner from placing the DML subtree where the executor may skip it, rather than patching any individual join node — the two reproductions above already show two independent paths to the same outcome.
Related: #2491 (original report, also covers the visibility defect), #2493 (visibility defect), #2490.
Describe the bug
In a multi-part query, all writes can be silently discarded — not merely invisible to later clauses, but never executed at all. The statement succeeds, returns a plausible result, and leaves the database unchanged.
AGE's DML executes inside a
CustomScanburied in the plan tree rather than as a top-levelModifyTable. The planner is therefore free to place that subtree on either side of a join, and several executor nodes are entitled to skip an input entirely when the other input turns out to be empty. When the skipped input is the one containing theCypher Createnode, the writes never happen.The empty input is typically the scan of the very label the query is writing to — which is empty precisely because the node that would populate it is the side being skipped. Chicken-and-egg, and completely silent.
Split out of #2491, which hits this as well as the separate visibility defect tracked in #2493. This issue is only the discarded-writes half.
How are you accessing AGE (Command line, driver, etc.)?
Command line,
psql.What data setup do we need to do?
None beyond an empty graph.
What is the necessary configuration info needed?
None. Default configuration, no extra extensions.
What is the command that caused the error?
No error is raised. Afterwards the graph is empty — zero nodes and zero edges:
EXPLAIN (ANALYZE)shows the writes were never executed:The merge join exhausts its left input, concludes the join can produce nothing, and never pulls the right input — which is where the
CREATElives.It is not specific to merge joins. The query as filed in #2491 reaches the same outcome through a hash join, where an empty build side lets PostgreSQL skip reading the outer relation entirely:
Two different join strategies, same failure — so this is not one unlucky plan shape.
What decides whether it fires
Plan shape, not input-row count. Measured on
master@ 8014174:MATCHscans the label just written; 2 input rowsCREATE (:N)-[:R {id:1}]->(:N))WITH i AS keepinstead ofWITH count(*):RedgeMATCHscans a different empty label than the one writtenCREATE, laterMATCHwithWHERE r.id = item.id)The failing cases share one property: the scan of the label being written back has no visible rows at execution time. Pre-existing rows in that table are enough to make the same query behave.
Expected behavior
A write clause must execute for every input row it receives, regardless of the join strategy chosen for clauses that read afterwards. Query results may be debatable under AGE's current visibility rules (see #2493), but a
CREATEthat the user asked for must never be silently skipped.Environment (please complete the following information):
master@ 8014174 (1.8.0)Also reproduces on 1.7.0 — the original report in #2491 was against the 1.7.0 Docker image.
Additional context
Severity is worth noting: #2493 produces wrong answers, which is bad but visible. This one silently drops committed-looking work with no error, so an application has no way to detect that its writes did not happen short of reading back.
Fixing it likely means preventing the planner from placing the DML subtree where the executor may skip it, rather than patching any individual join node — the two reproductions above already show two independent paths to the same outcome.
Related: #2491 (original report, also covers the visibility defect), #2493 (visibility defect), #2490.