Postgres SQL/PGQ

PostgreSQL version 19 introduces new features to the most advanced Open Source RDBMS. Among the many new features planned there are graph queries, which allow querying data structured as graphs. In this page we present, with simple and practical examples, how to use Graph Queries with PostgreSQL.

After a brief introduction we will use a complete example to show the main aspects of SQL/PGQ (Property Graph Query), covering the following topics: SQL data structures, SQL Property Graph, Property Graph Query, PGQViewer, EXPLAIN, JSON, Limitations, ...

Important: SQL/PGQ has been reverted in PostgreSQL 19 Beta 4 and will not be included in the PostgreSQL 19 production release. We look forward to seeing it in a future PostgreSQL release.

Introduction

PostgreSQL from version 19 introduces the ability to represent graph structures natively in the relational model.

A property graph has a different way of representing data compared to relational database tables. To query a property graph, pattern matching queries are used instead of traditional joins. PostgreSQL implements SQL/PGQ (Property Graph Query) as defined by the ISO/IEC 9075-16:2023 standard. In practice, two new constructs are added: CREATE PROPERTY GRAPH and GRAPH_TABLE.

In PostgreSQL a property graph is like a view that allows querying data with a special syntax, while the data is still stored in regular tables [Note: in native Graph Databases data is stored in graph structures: PostgreSQL's implementation is very different]. Graph query clauses and regular relational query clauses can be used in the same SQL statements and use the same execution mechanisms.
The CREATE PROPERTY GRAPH statement allows specifying how nodes (vertex) connect to each other (edge).
With GRAPH_TABLE you set search criteria on the graph and the MATCH clause specifies the connection condition.
In the PostgreSQL catalog, 5 new views have been added: pg_propgraph_element, pg_propgraph_label, pg_propgraph_property, pg_propgraph_element_label, and pg_propgraph_label_property, which describe each property of the graphs.

As always, the official documentation contains all the details. But now an example may be more useful!

Example

A good example is often much more useful than a long description.

The structures, data, and queries are freely taken from an excellent page on oracle-base. Of course there are differences between the Oracle and PostgreSQL implementations, but there are also similarities: both implement the SQL/PGQ standard! On this page we present the PostgreSQL implementation with working examples from version 19.

SQL Data Structures

We create the tables that correspond to nodes and connections. This is standard SQL and requires no special comments:

create table people (
  person_id bigint primary key,
  name      varchar(15)
);

insert into people (person_id, name)
values (1, 'Wonder Woman'),
       (2, 'Peter Parker'),
       (3, 'Jean Grey'),
       (4, 'Clark Kent'),
       (5, 'Bruce Banner');

create table connections (
  connection_id  bigint primary key,
  person_id_1    bigint,
  person_id_2    bigint,
  constraint connections_people_1_fk foreign key (person_id_1) references people (person_id),
  constraint connections_people_2_fk foreign key (person_id_2) references people (person_id)
);

create index concurrently connections_person_1_idx on connections (person_id_1);
create index concurrently connections_person_2_idx on connections (person_id_2);

insert into connections (connection_id, person_id_1, person_id_2)
values (1,  1, 2),
       (2,  1, 3),
       (3,  1, 4),
       (4,  2, 4),
       (5,  3, 1),
       (6,  3, 4),
       (7,  3, 5),
       (8,  4, 1),
       (9,  5, 1),
       (10, 5, 2),
       (11, 5, 3);


create table products (
  product_id  bigint primary key,
  name        varchar(10)
);

insert into products (product_id, name)
values (1, 'apple'),
       (2, 'banana'),
       (3, 'lemon'),
       (4, 'lime');

create table sales (
  sale_id     bigint primary key,
  person_id   bigint,
  product_id  bigint,
  quantity    bigint,
  constraint sales_people_fk foreign key (person_id) references people (person_id),
  constraint sales_products_fk foreign key (product_id) references products (product_id)
);

create index concurrently sales_person_id_idx on sales (person_id);
create index concurrently sales_product_idx on sales (product_id);

insert into sales (sale_id, person_id, product_id, quantity)
values (1, 1, 2, 10),
       (2, 1, 3, 5),
       (3, 2, 2, 6),
       (4, 2, 1, 4),
       (5, 3, 2, 3),
       (6, 3, 1, 6),
       (7, 3, 3, 2),
       (8, 4, 3, 8),
       (9, 5, 2, 6),
       (10, 5, 4, 5),
       (11, 5, 3, 3);

analyze people;
analyze connections;
analyze products;
analyze sales;

SQL Property Graph

A Property Graph is a model that describes nodes (vertex) and the relationships between them (edge). In PostgreSQL, vertices and edges are regular tables, views, foreign tables, etc. Property Graphs are the metadata that describe the existing relationships.

We create a property graph where the PEOPLE table represents nodes and the CONNECTIONS table represents relationships. With LABEL it is possible to assign a name that can be used later in queries. It is important to note that the graph is directed, meaning connections have a SOURCE KEY and a DESTINATION KEY.
Here are the commands:

create property graph connections_pg vertex tables ( people key (person_id) label person properties all columns ) edge tables ( connections key (connection_id) source key (person_id_1) references people (person_id) destination key (person_id_2) references people (person_id) label connection properties all columns );

We create a second Property Graph for sales, where the PEOPLE and PRODUCTS tables are nodes and the SALES table represents relationships.
Here are the commands:

create property graph sales_pg vertex tables ( people key (person_id) label person properties all columns, products key (product_id) label product properties all columns ) edge tables ( sales key (sale_id) source key (person_id) references people (person_id) destination key (product_id) references products (product_id) label sale properties all columns );

The current definitions of the defined property graphs can be viewed by querying the data dictionary tables:

SELECT * FROM pg_propgraph_element;        -- graph nodes (vertex / edge tables)
SELECT * FROM pg_propgraph_element_label;  -- element ↔ label mapping
SELECT * FROM pg_propgraph_label;          -- model labels
SELECT * FROM pg_propgraph_label_property; -- label ↔ exposed properties mapping
SELECT * FROM pg_propgraph_property;       -- property expressions (not just columns but also computed expressions)

Property Graph Query

Now we can query the property graphs with GRAPH_TABLE:

select person1, person2 from graph_table (connections_pg match (p1 is person) -[c is connection]-> (p2 is person) columns (p1.name as person1, p2.name as person2) ) order by 1; person1 | person2 --------------+-------------- Bruce Banner | Jean Grey Bruce Banner | Peter Parker Bruce Banner | Wonder Woman Clark Kent | Wonder Woman Jean Grey | Clark Kent Jean Grey | Wonder Woman Jean Grey | Bruce Banner Peter Parker | Clark Kent Wonder Woman | Peter Parker Wonder Woman | Clark Kent Wonder Woman | Jean Grey (11 rows)

Of course it is possible to use conditions: PGQViewer

select person1, person2 from graph_table (connections_pg match (p1 is person where p1.name = 'Jean Grey') -[c is connection]-> (p2 is person) columns (p1.name as person1, p2.name as person2) ) order by 1; person1 | person2 -----------+-------------- Jean Grey | Wonder Woman Jean Grey | Clark Kent Jean Grey | Bruce Banner (3 rows) select person1, person2 from graph_table (connections_pg match (p1 is person where p1.name = 'Jean Grey') -[c is connection]-> (p2 is person) columns (p1.name as person1, p2.name as person2) ) where person2 = 'Clark Kent' order by 1; person1 | person2 -----------+------------ Jean Grey | Clark Kent (1 row) select person1, product, person2 from graph_table (sales_pg match (p1 is person) -[s1 is sale]-> (pr is product) <-[s2 is sale]- (p2 is person) columns (p1.name as person1, p2.name as person2, pr.name as product) ) where person1 != person2 order by 1; person1 | product | person2 --------------+---------+-------------- Bruce Banner | banana | Jean Grey Bruce Banner | lemon | Wonder Woman Bruce Banner | lemon | Jean Grey Bruce Banner | lemon | Clark Kent Bruce Banner | banana | Wonder Woman Bruce Banner | banana | Peter Parker Clark Kent | lemon | Jean Grey Clark Kent | lemon | Wonder Woman Clark Kent | lemon | Bruce Banner Jean Grey | apple | Peter Parker Jean Grey | banana | Bruce Banner Jean Grey | banana | Peter Parker Jean Grey | banana | Wonder Woman Jean Grey | lemon | Bruce Banner Jean Grey | lemon | Clark Kent Jean Grey | lemon | Wonder Woman Peter Parker | banana | Wonder Woman Peter Parker | banana | Jean Grey Peter Parker | banana | Bruce Banner Peter Parker | apple | Jean Grey Wonder Woman | lemon | Bruce Banner Wonder Woman | banana | Jean Grey Wonder Woman | banana | Peter Parker Wonder Woman | lemon | Jean Grey Wonder Woman | lemon | Clark Kent Wonder Woman | banana | Bruce Banner (26 rows)

PGQViewer

To visualize the graphs we use a very recent Open Source application: PGQViewer.
Installation is very simple because it runs on a container:

git clone https://github.com/aoncodev/PGQViewer.git
cd PGQViewer
docker build -t pgqviewer .
docker run --rm -p 127.0.0.1:8080:8080 -v pgqviewer-data:/data pgqviewer

Then you access the graphical application with a browser at the URL http://localhost:8080/.

The PostgreSQL database must be accessible from the container. If it is on the same machine as the host, simply use host.docker.internal. The other connection parameters are the typical PostgreSQL ones. [Note: on Linux you also need to add --add-host=host.docker.internal:host-gateway to the run command.]

Select the property graph connections_pg and set the following query:
 (p1 is person) -[c is connection]-> (p2 is person)

We get the following visualization: PGQViewer

Select the property graph sales_pg and set the following query:
 (p1 is person) -[s1 is sale]-> (pr is product) <-[s2 is sale]- (p2 is person)

We get the following visualization: PGQViewer

Of course it is possible to experiment with all the graphical capabilities of PGQViewer and the different conditions of graph queries!

Query Transformation

In PostgreSQL a property graph is like a view that allows querying data with a special syntax, while the data is still stored in regular tables, views, foreign tables, etc. In short, the normal objects that PostgreSQL manages.
The implementation of property graph searches is similar to the view mechanism, which in PostgreSQL is implemented using translation rules. For property graphs, the metadata is used and in the end a SQL statement is composed with the necessary joins, CTEs and whatever else is needed to obtain the correct data.

To check the execution of a property graph query and, if necessary, perform tuning, the standard PostgreSQL commands are used: SQL/PGQ PEV2

EXPLAIN (COSTS OFF, PLAN_ADVICE) select person1, product, person2 from graph_table (sales_pg match (p1 is person) -[s1 is sale]-> (pr is product) <-[s2 is sale]- (p2 is person) columns (p1.name as person1, p2.name as person2, pr.name as product) ) where person1 != person2 order by 1; QUERY PLAN --------------------------------------------------------------------------- Sort Sort Key: people.name -> Hash Join Hash Cond: (sales_1.person_id = people_1.person_id) Join Filter: (people.name <> people_1.name) -> Hash Join Hash Cond: (products.product_id = sales_1.product_id) -> Hash Join Hash Cond: (sales.product_id = products.product_id) -> Hash Join Hash Cond: (sales.person_id = people.person_id) -> Seq Scan on sales -> Hash -> Seq Scan on people -> Hash -> Seq Scan on products -> Hash -> Seq Scan on sales sales_1 -> Hash -> Seq Scan on people people_1 Generated Plan Advice: JOIN_ORDER(sales people products sales#2 people#2) HASH_JOIN(people products sales#2 people#2) SEQ_SCAN(sales people products sales#2 people#2) NO_GATHER(people sales products sales#2 people#2) (25 rows)

It is also possible to set flags to get more details in the log files or recompile the sources in debug mode... but what has been reported should already be sufficient even for the most curious!

JSON

When tables are complex, it is preferable to define the list of columns to use in graph queries with the PROPERTIES clause. This also applies when expressions must be used or when you want to use data stored in JSON format.

We define a JSONB column to introduce new information:

alter table people add column json_data jsonb;

update people
   set json_data = json('{"gender":"female", "universe":"DC"}')
 where person_id = 1;

update people
   set json_data = json('{"gender":"male", "universe":"Marvel"}')
 where person_id = 2;

update people
   set json_data = json('{"gender":"female", "universe":"DC"}')
 where person_id = 3;

update people
   set json_data = json('{"gender":"male", "universe":"Marvel"}')
 where person_id = 4;

update people
   set json_data = json('{"gender":"male", "universe":"Marvel"}')
 where person_id = 5;

We can recreate the property graph with the new definitions:

drop property graph connections_pg; create property graph connections_pg vertex tables ( people key (person_id) label person properties (name, json_data->>'gender' AS gender, json_data->>'universe' AS universe) ) edge tables ( connections key (connection_id) source key (person_id_1) references people (person_id) destination key (person_id_2) references people (person_id) label connection properties (person_id_1, person_id_2) );

And query with:

select person1, gender1, universe1, person2, gender2, universe2 from graph_table (connections_pg match (p1 is person) -[c is connection]-> (p2 is person) columns (p1.name as person1, p1.gender as gender1, p1.universe as universe1, p2.name as person2, p2.gender as gender2, p2.universe as universe2) ) order by 1; person1 | gender1 | universe1 | person2 | gender2 | universe2 --------------+---------+-----------+--------------+---------+----------- Bruce Banner | male | Marvel | Jean Grey | female | DC Bruce Banner | male | Marvel | Peter Parker | male | Marvel Bruce Banner | male | Marvel | Wonder Woman | female | DC Clark Kent | male | Marvel | Wonder Woman | female | DC Jean Grey | female | DC | Clark Kent | male | Marvel Jean Grey | female | DC | Wonder Woman | female | DC Jean Grey | female | DC | Bruce Banner | male | Marvel Peter Parker | male | Marvel | Clark Kent | male | Marvel Wonder Woman | female | DC | Peter Parker | male | Marvel Wonder Woman | female | DC | Clark Kent | male | Marvel Wonder Woman | female | DC | Jean Grey | female | DC (11 rows)

Limitations

If we search for people that are not directly connected to each other and the distance in the property graph connections_pg, we would expect:

... ORDER BY distance, person1, person2; person1 | person2 | distance --------------+--------------+---------- Clark Kent | Bruce Banner | 2 Peter Parker | Jean Grey | 2 (2 rows)

But the result shown was obtained with a complex recursive SQL:

WITH RECURSIVE graph_edges AS (
    -- we transform the graph into undirected
    SELECT person_id_1 AS src, person_id_2 AS dst
    FROM connections
    UNION
    SELECT person_id_2 AS src, person_id_1 AS dst
    FROM connections
),
bfs AS (
    -- starting point: each node is itself
    SELECT
        p.person_id AS start_id,
        p.person_id AS current_id,
        0 AS distance,
        ARRAY[p.person_id] AS visited
    FROM people p
    UNION ALL
    -- BFS expansion (Breadth-First Search)
    SELECT
        bfs.start_id,
        e.dst,
        bfs.distance + 1,
        bfs.visited || e.dst
    FROM bfs
    JOIN graph_edges e
        ON e.src = bfs.current_id
    WHERE NOT e.dst = ANY(bfs.visited)
),
min_dist AS (
    SELECT
        start_id,
        current_id,
        MIN(distance) AS distance
    FROM bfs
    GROUP BY start_id, current_id
)
SELECT
    p1.name AS person1,
    p2.name AS person2,
    md.distance
FROM min_dist md
JOIN people p1 ON p1.person_id = md.start_id
JOIN people p2 ON p2.person_id = md.current_id
WHERE md.distance>1
  AND md.start_id < md.current_id
ORDER BY distance, person1, person2;

   person1    |   person2    | distance 
--------------+--------------+----------
 Clark Kent   | Bruce Banner |        2
 Peter Parker | Jean Grey    |        2
(2 rows)


-- Version with strings
WITH RECURSIVE graph_edges AS (
    SELECT person_id_1 AS src, person_id_2 AS dst
    FROM connections
    UNION
    SELECT person_id_2 AS src, person_id_1 AS dst
    FROM connections
),
bfs AS (
    SELECT
        p.person_id AS start_id,
        p.person_id AS current_id,
        0 AS distance,
        '/' || p.person_id || '/' AS path
    FROM people p
    UNION ALL
    SELECT
        bfs.start_id,
        e.dst,
        bfs.distance + 1,
        bfs.path || e.dst || '/'
    FROM bfs
    JOIN graph_edges e
        ON e.src = bfs.current_id
    WHERE bfs.path NOT LIKE '%/' || e.dst || '/%'
),
min_dist AS (
    SELECT
        start_id,
        current_id,
        MIN(distance) AS distance
    FROM bfs
    GROUP BY start_id, current_id
)
SELECT
    p1.name AS person1,
    p2.name AS person2,
    md.distance
FROM min_dist md
JOIN people p1 ON p1.person_id = md.start_id
JOIN people p2 ON p2.person_id = md.current_id
WHERE md.distance > 1
  AND md.start_id < md.current_id
ORDER BY distance, person1, person2;

There are still several limitations in the PostgreSQL implementation of SQL/PGQ, which are all explicitly documented: Different-edges match mode, All shortest path search, ELEMENT_ID function, Quantified paths, etc.
Given that SQL/PGQ is not the implementation of a Graph Database and that PostgreSQL introduces only some of the features defined by the standard, this is certainly the first important step towards an increasingly complete and robust implementation!

Miscellaneous

More experienced users will have noticed the use of Plan Advice in the EXPLAIN: this is a new feature also introduced in version 19.

Version 19 is not yet available, but you can already install PG19 Beta1.

PostgreSQL is in continuous evolution: the project is almost 40 years old, the SQL Engine has been available for 30 years, JSON has been supported since version 9.2 [Note: since 2012 and with continuous improvements including the efficient JSONB datatype in 9.4], the pgvector extension has been available for years [Note: since 2021], the PG19 version, scheduled for 2026-09, will also introduce a first implementation of SQL/PGQ, ... the possibilities for hybrid relational and semi-structured queries continue to expand!


Title: Postgres SQL/PGQ
Level: Advanced (3/5)
Date: June 4th, 2026
Version: 1.1.0 - September 24th, 2026
Author: mail [AT] meo.bogliolo.name