Dev & EngARTICLE

PostgreSQL attempts graphs with SQL/PGQ and reverts the implementation before version 19

The SQL/PGQ feature was briefly merged into PostgreSQL 19 to query graphs within standard SQL, but it was pulled in September 2026 due to catalog issues. Here's how the rewrite into joins works and what's still missing to replace recursive CTEs in graph queries.

PostgreSQL attempts graphs with SQL/PGQ and reverts the implementation before version 19
Image: Roberto Diniz

PostgreSQL briefly merged, on March 16, 2026, an implementation of SQL/PGQ, the graph extension standardized in SQL:2023. The idea was to allow querying graph patterns (who knows whom, who fought where) inside a regular SELECT, without resorting to WITH RECURSIVE or an extension like Apache AGE.

In September, after a discussion on the pgsql-hackers mailing list about problems in the catalog representation and dependency tracking, the patch was reverted before the release of version 19. That trajectory, from commit to revert, is what a post on Planet PostgreSQL details, and it's worth unpacking for anyone who today solves graph problems by writing WITH RECURSIVE in PostgreSQL.

The problem SQL never solved well

The graph ecosystem never converged on a single language. Cypher (Neo4j) uses ASCII art to represent patterns; Gremlin (Apache TinkerPop) is imperative and step-based; PGQL (Oracle) embeds graph patterns inside a SQL-like shell. In 2024, GQL became ISO/IEC 39075, the first database query language ratified by ISO since SQL itself.

SQL/PGQ, standardized within SQL:2023, is different from the others: it's not a new language, it's an extension of SELECT that allows declaring a property graph over existing tables and querying it with pattern syntax similar to GQL and PGQL, inside a GRAPH_TABLE.

Why plain SQL gets ugly fast

For two hops, relational SQL is still tolerable: a double self-join solves "friends of friends." The problem appears when the question is about reachability, not a fixed number of hops. Asking for all people reachable from a given origin within up to five hops of a KNOWS edge turns into a WITH RECURSIVE with a path array to avoid cycles, depth counting, and removal of the seed row at the end:

sql
WITH RECURSIVE reachable(id, depth, path) AS (
 SELECT p.id, 0, ARRAY[p.id]
 FROM person p WHERE p.name = 'Alice'
 UNION ALL
 SELECT k.dst, r.depth + 1, r.path || k.dst
 FROM reachable r
 JOIN knows k ON k.src = r.id
 WHERE r.depth < 5 AND k.dst <> ALL(r.path)
)
SELECT DISTINCT p.name
FROM reachable r JOIN person p ON p.id = r.id
WHERE r.depth > 0;

The SEARCH and CYCLE clauses, added in PostgreSQL 14, hide part of that bookkeeping, but the query is still a recursive fixed-point calculation that, coincidentally, returns the right answer. In SQL/PGQ, the same question is a single pattern: MATCH (a IS Person WHERE a.name = 'Alice')-[IS KNOWS]->{1,5}(c IS Person).

How it looks in practice: Jedi, Sith, and ordinary tables

The post uses an example with tables jedi, sith, planet, and station, linked by fought_on_planet and fought_on_station. The graph is declared over them without moving a single byte of data:

sql
CREATE PROPERTY GRAPH galaxy
VERTEX TABLES (
 jedi KEY (id) LABEL force_user PROPERTIES (id, name, title),
 sith KEY (id) LABEL force_user PROPERTIES (id, name, title),
 planet KEY (id) LABEL place PROPERTIES (id, name, region),
 station KEY (id) LABEL place PROPERTIES (id, name, region)
)
EDGE TABLES (
 fought_on_planet SOURCE KEY (a) REFERENCES jedi (id)
 DESTINATION KEY (b) REFERENCES planet (id)
 LABEL fought_at PROPERTIES (battle),
 fought_on_station SOURCE KEY (a) REFERENCES sith (id)
 DESTINATION KEY (b) REFERENCES station (id)
 LABEL fought_at PROPERTIES (battle)
);

In the catalog, that CREATE PROPERTY GRAPH becomes a new relation kind (RELKIND_PROPGRAPH) that sits in pg_class with no heap and no columns of its own, behaving like a view. Five new catalogs, among them pg_propgraph_element and pg_propgraph_label, store elements, labels, and properties. Labels group tables: force_user covers jedi and sith; place covers planet and station.

Under the hood, it's all joins

This is the point that matters most for anyone deciding whether to adopt the feature down the line: PostgreSQL did not gain a graph executor. The parser turns GRAPH_TABLE into a new range table entry (RTE_GRAPH_TABLE), and the rewriter, the same stage that expands views, replaces it with an ordinary subquery, in four steps:

  • it reads the pattern left to right as a list of vertices, edges (in either direction), and nested subpatterns;
  • each unique variable name becomes a "path factor"; a repeated variable (a cycle that returns to the same point) shares a factor, with predicates joined by AND;
  • each label expression is resolved against the tables it covers, generating one path element per table;
  • a depth-first search (DFS) enumerates every combination of elements along the path, discards the ones that don't match the declared source and destination keys, and unions the valid ones with UNION ALL.

In the galaxy example, force_user has two tables, fought_at has two, place has two: 2 × 2 × 2 = 8 possible combinations, of which only two are valid (Jedi on a planet, Sith on a station). The final result is literally this:

sql
SELECT jedi.name, planet.name, planet.region
FROM jedi, fought_on_planet, planet
WHERE jedi.id = fought_on_planet.a AND planet.id = fought_on_planet.b
UNION ALL
SELECT sith.name, station.name, station.region
FROM sith, fought_on_station, station
WHERE sith.id = fought_on_station.a AND station.id = fought_on_station.b;

From that point on, the planner and executor see only joins and a union, nothing graph-specific. That's good news for anyone who already trusts PostgreSQL's EXPLAIN, indexes, and statistics: SQL/PGQ inherits all of that for free, instead of depending on a separate graph engine with its own optimizer.

There's a catch that changes how to read any benchmark comparing this approach to manual joins. In a fixed-length pattern, SQL/PGQ is forced to touch the vertex tables, because the pattern binds vertices as part of its semantics, even when no vertex property is requested, since there's no foreign key (FK) guaranteeing the link.

If you model the same graph by hand and define FKs between the edge and vertex tables, you only need to walk the edge table. There's a recent effort (not yet accepted) on the PostgreSQL hackers list to eliminate FK-based inner joins, which would benefit exactly the SQL/PGQ rewrite when the base tables already have FKs.

What was left out

The SQL:2023 standard defines 100 graph-related features. The implementation that was briefly accepted covered 26 of them: patterns with WHERE, vertex and edge in full and abbreviated form, label disjunction (label1|label2), property reference, GRAPH_TABLE, the full DDL (including ALTER and DROP), multiple labels per table, and views as a graph element. The remaining 74 concentrate on five mechanisms:

MechanismWhat it decidesSupport in PostgreSQL
Quantifiers ({n,m}, {n}, +, *)How many hops a pattern coversNot supported
Path modes (WALK, TRAIL, SIMPLE, ACYCLIC)What can repeat along the pathOnly WALK, implicit
Search prefixes (ALL, ANY, ALL SHORTEST, SHORTEST n)Which admissible paths are returnedOnly ALL, the default
Matching modes (REPEATABLE ELEMENTS, DIFFERENT EDGES)Whether an edge can be used twiceOnly REPEATABLE ELEMENTS
Multiple comma-separated path patterns in a single MATCHHow to join patterns via shared variablesExisting patch, deferred

PostgreSQL is not alone in this limitation: Oracle's SQL/PGQ is also narrow, the DuckPGQ extension only adds ANY SHORTEST, and it's Oracle's own older PGQL that covers most of these pieces.

Why the revert, and what it means for those already testing it

The March 16, 2026 commit was slated for version 19. In September, a discussion on the pgsql-hackers mailing list about problems in the catalog representation and object dependency tracking ended with a decision to revert the feature before release. The reviewers' argument was about timing: the catalog and dependency handling needed a cleaner foundation, something easier to fix in version 20 without the pressure of maintaining backward compatibility with an already-released 19.

The good part for anyone following the work: the rewrite engine (the pipeline that turns GRAPH_TABLE into joins) is not what's being redesigned. That means that when the feature returns, the foundation should survive intact, and the missing pieces can be built on top of it: variable-length edges (VLE), multiple patterns, quantifiers.

It's worth noting a structural point that explains why this could only happen inside the core: PostgreSQL has no parser hook, so an extension can't add new grammar. Parser hooks have been repeatedly proposed and discussed without moving forward; the most recent mention dealt with how to ship SQL/PGQ as an experimental feature outside the main tree. That leaves only two options for this kind of syntax: live inside the core, or do what Apache AGE does, which is to parse a string at runtime, something possible in an extension, but not in a core feature.

The piece everyone asks for: variable-length edges

Of the missing parts, one dominates every conversation about graph querying: variable-length edges (VLE), the feature that's usually the reason someone switches from joins to a graph language. A pattern limited to two or three hops is manageable with a rewrite similar to what SQL/PGQ already does. A pattern capable of traversing hundreds of hops, or running unbounded until it exhausts the graph, is a different problem, with its own requirements around memory, termination, and cycle handling.

The standard complicates this by defining mechanisms that interact with one another: how far a pattern goes, which paths count as admissible, and which of those are returned are independent decisions, and each combination implies a different execution strategy. There are at least three possible implementation paths, each suited to a different goal:

  1. rewrite into WITH RECURSIVE with SEARCH/CYCLE, doing set-oriented BFS via joins (the simplest path, but not very flexible for combining with other semantics);
  2. expand each possible length into a UNION ALL branch (the approach used by Oracle, infeasible for the unbounded case);
  3. executor-level strategies, with DFS maintaining path state for backtracking, or direct BFS in the executor, requiring new execution nodes.

What changes for developers today

For anyone maintaining graph queries in production on PostgreSQL right now, the practical takeaway is twofold. First, SQL/PGQ is not available in any released version: the work of migrating WITH RECURSIVE to GRAPH_TABLE has nowhere to run yet, and it's not worth rewriting anything based on this standard until it reappears in a future version, possibly 20.

Second, even when it returns, the feature doesn't replace recursive CTEs for the most common production case, which is variable-depth traversal: without VLE, without ALL SHORTEST, and without ACYCLIC mode, the initial SQL/PGQ is good for declaring a graph and doing fixed-pattern joins, not for reachability or shortest path. Anyone who needs that today stays with a well-written WITH RECURSIVE, with SEARCH/CYCLE and correct indexes on the join columns, or evaluates an extension like Apache AGE, aware that it too parses strings at runtime due to the lack of a parser hook in the core.

Source

  1. Planet PostgreSQL, "PostgreSQL as a graph database, part 1: SQL/PGQ in PostgreSQL" (https://postgr.es/p/9zj).

Translated from the Brazilian Portuguese original · Read the original