pgrun.dev
← All posts

Postgres 19 can query relationships like a graph

· Alex Shapalov

Postgres 19 can query relationships like a graph. Not with a long manual join chain — with a path:

customer → bought → product ← bought ← similar customer → follows → brand

That is a recommendation engine in one readable pattern. The pattern finds the relationship; COLUMNS decides what comes back.

The join chain you don't write

Say you want to recommend brands to a customer: find the people who bought the same products, then look at which brands they follow. In SQL as we have written it for thirty years, that is a chain of self-joins.

SELECT DISTINCT b.name
FROM customers c
JOIN purchases p1     ON p1.customer_id = c.id
JOIN purchases p2     ON p2.product_id = p1.product_id
                     AND p2.customer_id <> c.id
JOIN brand_follows bf ON bf.customer_id = p2.customer_id
JOIN brands b         ON b.id = bf.brand_id
WHERE c.email = 'ada@example.com';

It works. It has always worked. But the shape of the question — hop from a customer out to a product, then back in to a different customer — is invisible. It lives in the ON clauses, and you rebuild it in your head every time you read the query.

Define the graph once

Postgres 19 adds CREATE PROPERTY GRAPH, which labels tables you already have as vertices and edges.

CREATE PROPERTY GRAPH shop_graph
  VERTEX TABLES (
    customers,
    products,
    brands
  )
  EDGE TABLES (
    purchases     SOURCE customers DESTINATION products,
    brand_follows SOURCE customers DESTINATION brands
  );

No data moves. Nothing is copied into a new storage format. A property graph is metadata over the tables and foreign keys you already have — purchases is still an ordinary table you insert into, and shop_graph is simply a second way to read it. If your foreign keys are in place, that statement is the entire migration.

One pattern, one recommendation

Now the recommendation is the path, written out.

SELECT DISTINCT recommended_brand
FROM GRAPH_TABLE (
  shop_graph
  MATCH (c IS customers WHERE c.email = 'ada@example.com')
          -[IS purchases]->(p IS products)
          <-[IS purchases]-(similar IS customers)
          -[IS brand_follows]->(b IS brands)
  COLUMNS (
    c.id       AS customer_id,
    similar.id AS similar_id,
    b.name     AS recommended_brand
  )
)
WHERE similar_id <> customer_id;

Read the MATCH clause aloud and it is the sentence you started with: this customer bought a product, another customer bought that same product, that customer follows a brand. The -> and <- are the direction of the edge, so the reversal in the middle — back out from the product to a different buyer — is one character rather than a join condition.

COLUMNS is the projection. The pattern decides which rows exist; COLUMNS decides which of the bound variables you get back. Every element in the path is addressable, so adding p.name would tell you why each brand was recommended.

One thing to know before you write your first one: GRAPH_TABLE has no standalone WHERE between MATCH and COLUMNS. A filter either attaches to a single element pattern, the way c.email does above, or it goes in the outer query — which is what similar_id <> customer_id is doing, to stop the customer being recommended their own brands.

It's a rewriter, not a graph engine

Nothing about your storage changes. A graph pattern is rewritten into the tree of joins and filters it describes, and handed to the same planner as everything else. Three consequences worth internalising:

  • EXPLAIN still works, and still looks familiar. You will see hash joins and index scans, not a new class of node you have no intuition for.
  • Indexes matter exactly as much as they did. A slow GRAPH_TABLE query is a slow join, and the fix is the fix you already know.
  • There is no new service. No second daemon, no separate backup story, no extra replication path.

That last one is the point. This is a change to the query language, not to the operational surface — you are not adding a database, you are learning three keywords.

Should you use it yet

Postgres 19 reached Beta 1 on 4 June 2026, with the release expected in September, so today this is something to try rather than something to ship. GRAPH_TABLE implements ISO/IEC 9075-16:2023 — SQL/PGQ, part 16 of the SQL standard — so it is not a Postgres dialect you would be locking yourself into.

The first implementation covers fixed-length patterns: you write the hops you want, one arrow each. There is no {1,3} quantifier, no variable-length traversal and no shortest-path. If your question is "how are these two accounts connected, however far apart they turn out to be", a dedicated graph database answers it and Postgres does not.

That is not most graph questions. Most of them are two or three hops over data that already lives in Postgres — recommendations, permissions, org charts, fraud rings, dependency trees — and what made them painful was never depth. It was that the query was unreadable.

Postgres has already absorbed the document store, the search index, the job queue and the vector database. Relationship queries are next, and as usual you get them without running anything new.