SQL/PGQ, and why your relational database was a graph all along
I have spent 7 years moving data between systems. One pattern repeats more than any other.
A team hits a slow query. They buy a specialised database to fix it. Three years later they are maintaining a pipeline instead of building features.
Graph databases are the classic case. Someone needs to find “customers who share a device with a customer who charged back.” They write a recursive CTE. It takes 90 seconds. By the end of the quarter there is a Neo4j cluster, a Kafka topic, a schema drift problem, and a new on-call rotation. All to answer a question about data that never left Postgres.
PostgreSQL 19 changes this. Beta 1 landed on June 4, 2026, and it ships SQL/PGQ: SQL Property Graph Queries, standardised as ISO/IEC 9075-16:2023.
SQL/PGQ lets you declare a graph over tables you already have. Then you query it with graph syntax. No new storage engine. No extension. No ETL. No second copy of your data.
It is a bigger deal than the release notes suggest. It also does less than the hype suggests. This post covers both halves.
1. What actually shipped
The core idea is simple: a property graph is a view.
You are not creating an object that stores anything. You write DDL that says which of your tables are nodes, which are edges, and how they connect. Postgres saves that in the catalog.
Here is the important part. When you run a graph query, the rewriter turns your pattern into ordinary joins. This happens before the planner ever sees it.
So everything you already have keeps working:
- Your indexes work
- Your table statistics work
EXPLAINworks- Parallel query works
- Row-level security works
In other words, it is the same machinery. You just get a new front door.
Declaring a graph
A graph has two parts. Vertex tables are your nodes. Edge tables are the connections between them.
CREATE PROPERTY GRAPH social_graph
VERTEX TABLES (
users LABEL person
PROPERTIES (id, name, email, joined_at),
posts LABEL post
PROPERTIES (id, title, created_at)
)
EDGE TABLES (
follows
SOURCE KEY (follower_id) REFERENCES users (id)
DESTINATION KEY (followed_id) REFERENCES users (id)
LABEL follows
PROPERTIES (created_at),
likes
SOURCE KEY (user_id) REFERENCES users (id)
DESTINATION KEY (post_id) REFERENCES posts (id)
LABEL liked
PROPERTIES (created_at)
);That is the long form. If your tables already have primary keys and foreign keys, Postgres works most of it out for you:
CREATE PROPERTY GRAPH myshop
VERTEX TABLES (products, customers, orders)
EDGE TABLES (
order_items SOURCE orders DESTINATION products,
customer_orders SOURCE customers DESTINATION orders
);
Six lines. A working graph over an existing schema. Zero data moved.
How to read the syntax
The arrows look strange at first. They are simpler than they appear.
(a IS person) -[IS follows]-> (b IS person)
└────┬────┘ └─────┬────┘ └────┬────┘
a node an edge a node
Four rules cover almost everything:
| Symbol | Meaning |
( ) round brackets | a node, one row in a vertex table |
[ ] square brackets | an edge, one row in an edge table |
-> arrow | which way you are travelling |
IS label | “this must have this label” |
So the pattern above reads: a person a, who follows, a person b.
Two more things to know.
The word before IS is an alias. You only need it if you want to use that element later:
-[f IS follows]-> named f, so you can read f.created_at in COLUMNS
-[IS follows]-> no name, you just want to filter by the label
-[f]-> any edge at all, named f
IS matches the label, not the table name. In the example above the table is called follows and its label is also follows. That is only because Postgres uses the table name as the default label. If you had written LABEL knows, the pattern would be -[IS knows]-> even though the table is still follows.
Querying it
Graph queries go inside GRAPH_TABLE:
SELECT * FROM GRAPH_TABLE (social_graph
MATCH (a IS person WHERE a.name = 'Alice')
-[f IS follows]->(b IS person)
COLUMNS (b.name AS followed_name)
);
GRAPH_TABLE takes three things: a graph name, a MATCH pattern, and a COLUMNS list. It returns a normal relation.
That last point matters a lot. Because the result is a normal relation, plain SQL wraps around it:
SELECT followed_name, count(*) AS follower_count
FROM GRAPH_TABLE (social_graph
MATCH (a IS person)-[IS follows]->(b IS person)
COLUMNS (b.name AS followed_name)
)
GROUP BY followed_name
HAVING count(*) > 1
ORDER BY follower_count DESC; Aggregates, window functions, CTEs, joins against non-graph tables. All of it works. This is the single biggest advantage over a bolt-on graph database, and I come back to it later.
The full pattern grammar
| Syntax | Meaning |
(v IS label) | a node with this label, named v |
(v IS label WHERE cond) | same, with a filter |
-[e IS label]-> | edge going out |
<-[e IS label]- | edge coming in |
-[e IS label]- | edge in either direction |
pattern, pattern | two patterns at once, sharing names |
That is the whole language. You can learn it in an afternoon.
Managing graphs
ALTER PROPERTY GRAPH social_graph ADD EDGE TABLE ...;
DROP PROPERTY GRAPH social_graph; does NOT touch your tables
In psql, \dG lists your graphs. The catalog tables are pg_propgraph_element, pg_propgraph_label, pg_propgraph_property and pg_propgraph_label_property.
2. Why graphs matter at all
Here is the thing nobody says plainly. Relational databases are good at graphs. They are bad at expressing graphs.
Take a three-hop question. “Which products were bought by customers who share a payment card with a customer who filed a chargeback?”
In SQL that is six joins. Three of them are self-joins on the same table with different aliases. You will spend twenty minutes checking that c1, c2 and c3 are on the right side of each condition.
As a result, the query ends up correct-ish and unreadable. The next person to touch it rewrites it from scratch, because reading it is harder than rewriting it.
By contrast, the graph version is one line that looks like the sentence you said out loud.
Above all, that is the real benefit, and it is a human one. Graph syntax shortens the distance between the question and the query. When your fraud analyst can read the query, the query gets reviewed. When it gets reviewed, it gets fixed.
There is a second reason, and this one is technical. It is about join depth.
Every hop in a relational traversal is a fresh index lookup. That is O(log n) into a B-tree, plus a heap fetch, plus whatever the planner decided about join order.
Native graph engines work differently. They use index-free adjacency: each node physically stores pointers to its neighbours. So each hop is O(degree). A pointer chase, not a search.
On a billion-edge graph, ten hops deep, that gap is not 2x. It is closer to three orders of magnitude.
Remember that number. It is the honest boundary of what PG19 can do, and section 5 comes back to it.
Where graph shapes show up
Almost everywhere, once you start looking:
- Fraud and AML. Shared devices, addresses, cards, IPs. Ring detection is a cycle query.
- Identity resolution. Merging customer records across systems is connected-component finding.
- Permissions. User to group to role to permission to resource is four hops. You probably run it on every request.
- Supply chain. A bill of materials is a graph, full stop.
- Recommendations. “Customers who bought X also bought Y” is a two-hop traversal.
- Org and franchise hierarchies. Reporting lines, multi-location networks, ownership chains.
- Data lineage. Which dashboards break if I drop this column?
- GraphRAG. Retrieval over an entity graph instead of a vector blob. A lot of serious RAG work has moved this way.
Notice how many of these you are already doing. You do them with recursive CTEs and hand-written join chains. You have a graph workload. You just called it “the reporting query.”
3. Why Postgres, and why now
Three things came together.
The standard finally exists
SQL/PGQ became part of ISO SQL in 2023.
Before that, “graph query language” meant one of three things. Cypher, from Neo4j. Gremlin, from Apache. PGQL, from Oracle. Three dialects, no portability, and an obvious lock-in problem. Most enterprise architects would not sign off on it.
A standard changes procurement, not just syntax.
Postgres keeps absorbing other databases
The last five years have been a slow retreat from using a different database for every job. Look at what Postgres has taken over:
| It replaced | With |
| Document store | JSONB |
| Search index | tsvector, pg_trgm |
| Time-series DB | partitioning, TimescaleDB |
| Vector DB | pgvector |
| Job queue | SKIP LOCKED |
Therefore, graph was the missing piece.
Every one of those wins happened for the same reason. One copy of the data. One transaction boundary. One backup. One security model. Correctness beats specialisation for the common case, and the common case is most of the market.
AI made relationships load-bearing
Vector search finds things that are similar. It cannot find things that are connected.
GraphRAG, agent memory, entity resolution over LLM-extracted facts. All of it needs traversal. Until now, all of it needed a second database.
And one less flattering reason
Apache AGE split the ecosystem, and nobody was happy about it.
AGE gives you Cypher on Postgres. But it is an extension with its own storage, its own catalog, limited mixing with plain SQL, and a support matrix that lags behind core releases. It works. It just never felt like Postgres.
In contrast, core SQL/PGQ does feel like Postgres, because it is not a bolt-on. It is the rewriter.
How it got built
Peter Eisentraut posted the first prototype in February 2024. His own word for it was “fragile.”
Ashutosh Bapat added WHERE inside patterns and fixed the memory bugs. Others added cyclic patterns, permissions, RLS support, collation rules, LABELS() and PROPERTY_NAMES(), multi-pattern matching and ECPG support.
Two years. Around 15,000 lines across a hundred-plus files. And a deliberate choice to ship a small correct subset first.
That is the process you want behind a feature you are going to run a fraud system on.
4. A practical example
The most useful graph pattern in production is not a deep traversal. It is the shared neighbour.
Two things are not connected to each other. But both point at the same third thing.
Once you can see that shape, you find it everywhere. Two accounts on one device. Two customers on one card. Two products in one basket. Two people in one group.
Here it is on the most generic schema possible. Some entities, the groups they belong to, and a junction table joining them.
Tables you already have. Nothing about them changes.
CREATE TABLE people (id bigserial PRIMARY KEY, name text, email text);
CREATE TABLE organizations (id bigserial PRIMARY KEY, name text);
A junction table. This is an edge, and you didn't know it.
CREATE TABLE memberships (
person_id bigint REFERENCES people(id),
org_id bigint REFERENCES organizations(id),
role text,
joined_at timestamptz,
PRIMARY KEY (person_id, org_id)
);
Now the graph. This is the whole migration:
CREATE PROPERTY GRAPH network
VERTEX TABLES (
people LABEL person PROPERTIES (id, name, email),
organizations LABEL organization PROPERTIES (id, name)
)
EDGE TABLES (
memberships
SOURCE people DESTINATION organizations
LABEL belongs_to PROPERTIES (role, joined_at)
);
Zero rows written. Zero downtime. Reversible with one DROP.
The question: who else belongs to an organization that Alice belongs to?
Before
This is the query you would write today:
SELECT DISTINCT p2.id, p2.name
FROM people p1
JOIN memberships m1 ON m1.person_id = p1.id
JOIN memberships m2 ON m2.org_id = m1.org_id
JOIN people p2 ON p2.id = m2.person_id
WHERE p1.name = 'Alice'
AND p2.id <> p1.id;
Four joins. Two of them are self-joins on the same table with different aliases.
It is correct. It is also the kind of query where swapping m1.org_id and m2.person_id gives you something that still runs, still returns rows, and is quietly wrong.
After
SELECT DISTINCT peer_id, peer_name
FROM GRAPH_TABLE (network
MATCH (a IS person WHERE a.name = 'Alice')
-[IS belongs_to]->(o IS organization)
<-[IS belongs_to]-(peer IS person)
COLUMNS (peer.id AS peer_id, peer.name AS peer_name)
);
In fact, same plan. Same indexes. Same runtime.
What changed is that you can now see the answer in the shape of the text. Two arrows meeting at one o. Alice goes out to an organization. Someone else comes back in from it.
You can check that line for correctness by looking at it. You cannot do that with the four-alias version.
It composes
GRAPH_TABLE returns an ordinary relation, so plain SQL wraps straight around it:
SELECT peer_name, count(*) AS shared_orgs
FROM GRAPH_TABLE (network
MATCH (a IS person WHERE a.name = 'Alice')
-[IS belongs_to]->(o IS organization)
<-[IS belongs_to]-(peer IS person) COLUMNS (peer.name AS peer_name) ) GROUP BY peer_name HAVING count(*) > 1
ORDER BY shared_orgs DESC;
That reads: “people who share more than one organization with Alice, most overlap first.”
A dedicated graph database does not give you that for free. You would pull the rows back to your application and count them there. Here it is a GROUP BY.
The same query, five products
Swap the nouns. The pattern does not change.
person | organization | and it becomes |
| account | device fingerprint | fraud ring detection |
| customer | payment card | identity resolution |
| product | order | “customers also bought” |
| user | permission group | shared-access audit |
| author | publication | co-authorship network |
That is why the syntax is worth learning. Not for any one query. For the fact that the query stops being domain plumbing and starts being a shape you recognise.
5. The limitations. Read this before you plan anything.
PG19’s implementation is deliberately conservative. What is missing is not an oversight. It is a community choosing to ship a correct subset rather than a shaky superset.
But you need to know exactly where the wall is.
Not supported in PostgreSQL 19
- ❌ Variable-length paths. You cannot write “one or more hops” or “between two and five hops.”
- ❌ Quantified patterns. No
*,+or{m,n}after an edge. - ❌ Open-ended traversal. No “all paths from A to B, any length.”
- ❌ Shortest path.
- ❌ Transitive closure.
- ❌ Security-definer graphs. Invoker semantics only.
- ❌ Graph-native indexes. There is no adjacency structure. Only your B-trees.
Of these, the first one is the one that matters.
Everything in PG19 is fixed depth. If you cannot write the number of hops as a literal number in the query, PG19 cannot express it.
Legal in PG19. Exactly two hops, spelled out.
MATCH (a IS person)-[IS belongs_to]->(o)<-[IS belongs_to]-(b IS person) NOT legal in PG19. "Two to five hops." MATCH (a IS person)-[IS belongs_to]->{2,5}(b IS person)
The workaround
Recursive CTEs still work, and you should keep them. Given any self-referencing table, say org_units (id, name, parent_id):
Full ancestry chain, unknown depth. Still the right tool in PG19.
WITH RECURSIVE chain AS (
SELECT id, name, parent_id, 1 AS depth
FROM org_units WHERE name = 'Northwest Branch'
UNION ALL
SELECT u.id, u.name, u.parent_id, chain.depth + 1
FROM org_units u JOIN chain ON u.id = chain.parent_id
WHERE chain.depth < 20 always bound your recursion
)
SELECT * FROM chain;
The honest performance note
The rewriter produces N joins for an N-hop pattern.
On the other hand, for two or three hops over indexed foreign keys, Postgres is genuinely competitive with a dedicated graph database. It always was. We just could not say it nicely before.
However, for deep, unbounded traversal over a large graph, index-free adjacency still wins. No amount of query rewriting closes that gap.
So do not let the excitement talk you out of Neo4j if you are doing ten-hop pathfinding on a billion edges. That is a real workload, and SQL/PGQ is not the answer to it.
6. Converting your relational database to a graph
This is the part people actually need.
The good news: you are not converting anything. There is no data migration. What you are doing is modelling. You are deciding which of your tables are nouns and which are verbs.
The decision procedure
Walk your schema. Put every table into one of four buckets.
Bucket A: entity tables become vertex tables
A table is a vertex if a row is a thing that exists on its own. Customers, products, orders, devices, accounts, users, locations.
The test: could a business person point at a row and name it? Then it is a vertex.
Bucket B: pure junction tables become edge tables
These have a composite primary key of exactly two foreign keys and no identity of their own. Things like order_items, customer_devices, user_roles, post_tags.
They map to edges with no ceremony at all:
EDGE TABLES ( order_items SOURCE orders DESTINATION products LABEL contains )If the junction table carries extra columns, expose them as edge properties. Edges having properties is the whole reason it is called a property graph.
Bucket C: entity tables with inline foreign keys become both
In practice, this is where most schemas live. It is also where most people get stuck.
orders.customer_id is a relationship. But there is no junction table to point at.
The trick: list the table a second time under an alias. Use the foreign key as the source and its own primary key as the destination.
EDGE TABLES (
orders AS placed
SOURCE KEY (customer_id) REFERENCES customers (id)
DESTINATION KEY (id) REFERENCES orders (id)
LABEL placed
)
Now orders is a vertex, because it is a thing with an id and a total and a status. It is also the source of a placed edge from customer to order. Same rows, two roles.
Likewise, a table with three foreign keys becomes three aliased edges.
Bucket D: leave it out
Audit logs. schema_migrations. Denormalised reporting tables. Event streams. Soft-delete tombstones. Translation tables.
A property graph is a lens, not a mirror. Every table you add is noise in every pattern you write.
Model the 20% of your schema that carries the questions you care about.
You can define several small graphs instead of one 200-table monster. A fraud_graph, a catalog_graph, an authz_graph. You absolutely should.
The awkward cases
Self-referencing foreign keys like parent_id, manager_id, replied_to_id. These are Bucket C. Source and destination are both the same table:
ALTER PROPERTY GRAPH network
ADD EDGE TABLE org_units AS reports_to
SOURCE KEY (id) REFERENCES org_units (id)
DESTINATION KEY (parent_id) REFERENCES org_units (id)
LABEL reports_to;
This is the highest-value conversion in most schemas. It is exactly where your recursive CTEs live.
Polymorphic associations like commentable_type and commentable_id. Hello, Rails.
SQL/PGQ needs a real foreign key target. So split the polymorphic table into one edge definition per concrete type, filtered by a view. Or normalise into explicit join tables.
Admittedly, this is the one place conversion costs you real work. It is also the one place where the graph model is telling you something true about your schema.
Composite keys are supported:
SOURCE KEY (tenant_id, customer_id) REFERENCES customers (tenant_id, id)This matters for multi-tenant systems. Do not drop the tenant column out of the key, or you will traverse across tenants.
Missing foreign key constraints are common in schemas that grew fast.
You can declare SOURCE KEY ... REFERENCES ... in the graph where no real constraint exists. Do not do it. Add the real constraints first.
Graph traversal over unenforced referential integrity will find paths through corrupt data and report them as fact. A fraud alert generated from a dangling foreign key is worse than no alert at all.
Multiple labels per table are allowed. You can model staff as both person and employee.
Tables that share a label must expose matching properties: same names, same types. Use this for genuine polymorphism. Do not use it to be clever.
A conversion checklist
- Pick one question, not a schema. “Detect device-sharing fraud rings” beats “graph-ify the database.” A graph built for one question is small, reviewable, and shippable this week.
- Add the missing foreign key constraints for the tables in scope. Use
NOT VALIDthenVALIDATE CONSTRAINTif the tables are large and busy. - Index every source and destination key. The rewriter produces joins, and joins want indexes. Junction tables usually need the reverse index too. If
(order_id, product_id)is the primary key, you probably also need(product_id, order_id). Traversal goes both ways. - Classify your tables into A, B, C and D. Write it down before you write any DDL. The modelling argument is the actual work.
- Write the CREATE PROPERTY GRAPH. It is catalog-only DDL. It takes milliseconds and touches no rows.
- Port one query. Keep the original. Diff the results with
EXCEPTin both directions. Both should return zero rows. - Run
EXPLAIN (ANALYZE, BUFFERS)on both. The plans should be nearly identical. If the graph version is worse, it is almost always a missing index on a traversal key. - Then expand, one question at a time.
Importantly, every step is reversible. DROP PROPERTY GRAPH removes a catalog entry and nothing else.
This is the lowest-risk “migration” I have ever recommended. It barely deserves the word.
Why you should do it
- Readability compounds. Traversal queries rot fastest. They get copy-pasted, mis-aliased, and never refactored. Making them legible is a lasting maintenance win.
- You get to delete a system. If you run a graph database only for two and three hop questions, you can probably retire it. Along with its CDC pipeline, its lag, and its separate permission model.
- One transaction boundary. Your graph result is consistent with the write that just committed. No replication lag between “the order was placed” and “the fraud graph knows about it.” For fraud and permissions, that is correctness, not convenience.
- One security model. RLS applies. Roles apply. Your auditor does not have to learn a second permission system.
- It is a standard. Portable to other engines that implement ISO/IEC 9075-16. Oracle already does.
- Trying it costs almost nothing. Declaring a property graph cannot make your existing SQL slower. It adds a catalog entry and a syntax option.
Why you might not
- Your traversals are genuinely deep and variable-length. Wait for the feature, or keep the specialised engine.
- You need shortest path, PageRank, community detection or centrality. SQL/PGQ is a query language, not a graph analytics library.
- You are on PG 18 or older and cannot upgrade this year.
- Your team writes three traversal queries a year. Then join syntax is fine, and this is a solution looking for a problem.
7. Pros and cons
Pros
| No data migration | The graph is a view over existing tables |
| No new infrastructure | Built into core. No extension, no cluster |
| Full SQL composability | CTEs, windows, aggregates, joins with normal tables |
| Existing indexes work | The rewriter emits ordinary joins |
| Transactional consistency | Same MVCC snapshot as your writes |
| Standards-based | ISO/IEC 9075-16:2023 |
| Security built in | Roles and RLS apply unchanged |
| Reversible | DROP PROPERTY GRAPH costs nothing |
| Readable | Patterns look like the question |
Cons
| No variable-length paths | Fixed hop counts only in PG19 |
| No shortest path or transitive closure | You still need recursive CTEs |
| No index-free adjacency | Deep traversal on huge graphs stays slow |
| No graph algorithms | No PageRank, centrality or community detection |
| No security-definer graphs | Invoker semantics only |
| Read-only | You change the base tables, not the graph |
| PG19 and up only | Realistically a 2026 to 2027 adoption curve |
| Modelling still required | Polymorphic associations need real work |
| Early implementation | Beta. Expect rough edges and plan surprises |
Versus the alternatives
| SQL/PGQ (PG19) | Apache AGE | Neo4j | |
| Language | ISO SQL standard | Cypher (extension) | Cypher |
| Storage | Your existing tables | Extension storage | Native graph |
| Variable-length paths | Not yet | Yes | Yes |
| Graph algorithms | No | Limited | Extensive |
| Deep traversal | Join-bound | Join-bound | Index-free adjacency |
| Installation | Built in | Extension | Separate system |
| Full SQL composability | Yes | Limited | No |
| Data duplication | None | None | Full copy plus sync |
| Operational cost | Zero marginal | Low | High |
Overall, the pattern is clear. SQL/PGQ wins on integration and loses on depth.
In short, pick accordingly. And notice that most production “graph” workloads are two or three hops.
8. Verdict
SQL/PGQ in PostgreSQL 19 is not a graph database. It is something more useful to more people.
It is an honest admission that your relational schema was already a graph, plus the syntax to say so.
The limitation everyone will complain about, no variable-length paths, is real and will be fixed. What ships today is the 80% case: bounded traversals over indexed foreign keys, written in a way a human can review.
If you run a fraud check, a permissions check, a recommendation query or a hierarchy walk, you can adopt this in an afternoon. Keep every index you have. Delete nothing but complexity.
And the migration story is the best part, because there is not one. You write six lines of DDL against a catalog and your database has a graph in it. If you do not like it, you drop it.
Seven years of watching teams buy a second database to answer a question about data in their first one. The fix turned out to be a view. That is usually how it goes.
Frequently Asked Questions
Is PostgreSQL 19 a graph database?
No. SQL/PGQ defines a property graph as a read-only view over your existing
relational tables. There is no graph storage engine, no adjacency structure, and
no data duplication – graph patterns are rewritten into ordinary joins before the
planner ever sees them.
Do I need to migrate data to use SQL/PGQ?
No. CREATE PROPERTY GRAPH is catalog-only DDL. It writes zero rows,
takes milliseconds, and DROP PROPERTY GRAPH removes the catalog entry
without touching your tables.
Does SQL/PGQ support variable-length paths?
Not in PostgreSQL 19. Every pattern is fixed depth – you cannot write quantifiers
like *, + or {2,5} after an edge. Shortest
path and transitive closure are also unsupported. Recursive CTEs remain the right
tool for unbounded traversal.
SQL/PGQ or Apache AGE – which should I use?
SQL/PGQ is built into core, uses ISO standard syntax, and composes with ordinary
SQL including aggregates and window functions. Apache AGE is an extension with its
own storage and Cypher syntax, but it does support variable-length paths. Choose AGE
if you need unbounded traversal today; choose SQL/PGQ for bounded traversals you want
readable and transactionally consistent.
When is PostgreSQL 19 released?
Beta 1 was announced on 4 June 2026 and Beta 2 on 16 July 2026. General
availability is scheduled for late 2026. Don’t run SQL/PGQ in production until the
final release.
Start here. Pick your worst multi-hop join query. Write it as a GRAPH_TABLE pattern. Show it to someone who does not write SQL. If they can read it, you have your business case.