PostgreSQL 19 introduces SQL/PGQ, the ISO/IEC 9075-16 (2023) syntax for querying graphs that live in regular relational tables. No extension is required and no data is copied: a property graph is metadata defined on top of the tables you already have, and graph queries are expressed against it. Two constructs do the work — CREATE PROPERTY GRAPH declares the graph, and GRAPH_TABLE queries it inside ordinary SQL statements.

Declaring the graph

The smallest useful example is a social network with two tables: a person table and a knows table mapping who knows whom.

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

CREATE TABLE person (

    id   int  PRIMARY KEY,

    name text NOT NULL,

    age   int  NOT NULL,

    city text NOT NULL

);

CREATE TABLE knows (

    a     int NOT NULL REFERENCES person(id),

    b     int NOT NULL REFERENCES person(id),

    since int NOT NULL,

    PRIMARY KEY (a, b)

);

INSERT INTO person VALUES

    (1, 'Alice', 30, 'Berlin'),

    (2, 'Bob',   25, 'Berlin'),

   (3, 'Carol', 35, 'Paris'),

    (4, 'Dan',   28, 'Paris'),

    (5, 'Eve',   40, 'London'),

    (6, 'Frank', 33, 'London');

INSERT INTO knows VALUES

    (1, 2, 2018), (2, 1, 2019),

    (1, 3, 2020), (2, 3, 2020), (3, 2, 2021),

    (3, 4, 2021), (4, 5, 2022),

    (5, 6, 2019), (6, 1, 2023);

A property graph is defined as follows.

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

friends=# \h CREATE PROPERTY GRAPH

Command:     CREATE PROPERTY GRAPH

Description: define a new SQL-property graph

Syntax:

CREATE [ TEMP | TEMPORARY ] PROPERTY GRAPH name

    [ {VERTEX|NODE} TABLES ( vertex_table_definition [, ...] ) ]

    [ {EDGE|RELATIONSHIP} TABLES ( edge_table_definition [, ...] ) ]

where vertex_table_definition is:

    vertex_table_name [ AS alias ]

[ KEY ( column_name [, ...] ) ]

[ element_table_label_and_properties ]

and edge_table_definition is:

    edge_table_name [ AS alias ] [ KEY ( column_name [, ...] ) ]

        SOURCE [ KEY ( column_name [, ...] )

REFERENCES ] source_table [ ( column_name [, ...] ) ]

        DESTINATION [ KEY ( column_name [, ...] )

REFERENCES ] dest_table [ ( column_name [, ...] ) ]

        [ element_table_label_and_properties ]

and element_table_label_and_properties is either:

    NO PROPERTIES | PROPERTIES ALL COLUMNS | PROPERTIES

( { expression [ AS property_name ] } [, ...] )

or:

   { { LABEL label_name | DEFAULT LABEL }

[ NO PROPERTIES | PROPERTIES ALL COLUMNS | PROPERTIES

( { expression [ AS property_name ] } [, ...] ) ] } [...]

URL: https://www.postgresql.org/docs/devel/sql-create-property-graph.html

Read the definition as a contract. Rows in person are labelled person — the graph label can differ from the underlying table name. Such nodes are called vertices, they are connected by edges, and they expose properties that in this example are simply columns. The edge definition describes how vertices link to one another.

1

2

3

4

5

6

7

8

9

10

11

CREATE PROPERTY GRAPH social

     VERTEX TABLES (

         person KEY (id) LABEL person

PROPERTIES (id, name, age, city)

     )

     EDGE TABLES (

         knows

             SOURCE      KEY (a) REFERENCES person (id)

             DESTINATION KEY (b) REFERENCES person (id)

             LABEL knows PROPERTIES (since)

     );

Querying vertices and walking edges

The simplest possible graph query lists people.

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

tutorial=# SELECT name

FROM GRAPH_TABLE (social

MATCH (p IS person)

COLUMNS (p.name)

)

ORDER BY name;

name  

-------

Alice

Bob

Carol

Dan

Eve

Frank

(6 rows)

It is logically equivalent to a plain relational projection.

1

SELECT name FROM person ORDER BY name

GRAPH_TABLE names the graph to use. The person vertex is bound to the alias p, and its name column is projected.

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

tutorial=# SELECT *

FROM  GRAPH_TABLE (social

MATCH (p IS person )

COLUMNS (p.id, p.name)

);

id | name  

----+-------

  1 | Alice

  2 | Bob

  3 | Carol

  4 | Dan

  5 | Eve

  6 | Frank

(6 rows)

tutorial=# SELECT *

FROM GRAPH_TABLE (social

MATCH (p IS person )

COLUMNS (p.*)

);

ERROR:  "*" is not supported here

LINE 1: ...OM GRAPH_TABLE (social MATCH (p IS person ) COLUMNS (p.*) );

Multiple columns are allowed in the COLUMNS clause, but * is not — the column list must be written out explicitly.

Answering "who knows whom?" requires walking from one vertex to another along an edge. A graph query walks vertices and edges: start at a person, follow the edges defined against the knows table (an edge of the graph) to the other person.

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

tutorial=# SELECT *

FROM GRAPH_TABLE (social

MATCH   (p IS person )-[IS knows]->(p2 IS person)

COLUMNS (p.id, p.name, p2.id, p2.name)

)

ORDER BY 1, 2, 3;

id | name  | id | name  

----+-------+----+-------

  1 | Alice |  2 | Bob

  1 | Alice |  3 | Carol

  2 | Bob   |  1 | Alice

  2 | Bob   |  3 | Carol

  3 | Carol |  2 | Bob

  3 | Carol |  4 | Dan

  4 | Dan   |  5 | Eve

  5 | Eve   |  6 | Frank

  6 | Frank |  1 | Alice

(9 rows)

The pattern (a)-[IS knows]->(b) is an edge pattern. The arrow -> follows the edge in the direction it was declared (SOURCE → DESTINATION); <- follows it in reverse. A bare - is undirected and traverses both directions, so rows are listed twice.

1

2

3

4

5

6

7

8

9

10

11

12

SELECT *

FROM   GRAPH_TABLE (social

MATCH   (p IS person )-[IS knows]-(p2 IS person)

COLUMNS (p.id, p.name, p2.id, p2.name)

) ORDER BY 1, 2, 3;

id | name  | id | name

----+-------+----+-------

  1 | Alice |  2 | Bob

  1 | Alice |  2 | Bob

  1 | Alice |  3 | Carol

…

(18 rows)

The undirected operator compiles to an OR clause, which is why the row count comes out wrong.

1

2

3

(person.id = knows.a AND person_1.id = knows.b)  OR

(person_1.id = knows.a AND person.id = knows.b)

An undirected pattern is most useful when the underlying data is conceptually symmetric.

Multiple hops

Friends of friends — people who know each other indirectly — require expanding the query with a second hop from the second vertex to a third.

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

SELECT *

FROM GRAPH_TABLE (social

MATCH (a IS person)-[IS knows]->

(b IS person)-[IS knows]->(c IS person)

COLUMNS (a.name AS a, b.name AS via, c.name AS c)                                                                                                              )                                                                                                                                                                      ORDER BY a, c, via;

   a   |  via  |   c  

-------+-------+-------

Alice | Bob   | Alice

Alice | Carol | Bob

Alice | Bob   | Carol

Alice | Carol | Dan

Bob   | Alice | Bob

Bob   | Carol | Bob

Bob   | Alice | Carol

Bob   | Carol | Dan

Carol | Bob   | Alice

Carol | Bob   | Carol

Carol | Dan   | Eve

Dan   | Eve   | Frank

Eve   | Frank | Alice

Frank | Alice | Bob

Frank | Alice | Carol

(15 rows)

The data reveals something worth noticing: Alice knows Bob, and Bob knows Alice, so the traversal can return Alice → Bob → Alice. Semantically that is usually not wanted, and a WHERE clause filters it out.

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

SELECT *

FROM GRAPH_TABLE (social

MATCH (a IS person)-[IS knows]->

(b IS person)-[IS knows]->(c IS person)

WHERE a.id <> c.id

COLUMNS (a.name AS a, b.name AS via, c.name AS c)

)

ORDER BY a, c, via;

   a   |  via  |   c  

-------+-------+-------

Alice | Carol | Bob

Alice | Bob   | Carol

Alice | Carol | Dan

Bob   | Alice | Carol

Bob   | Carol | Dan

Carol | Bob   | Alice

Carol | Dan   | Eve

Dan   | Eve   | Frank

Eve   | Frank | Alice

Frank | Alice | Bob

Frank | Alice | Carol

(11 rows)

What matters here is that the WHERE clause may sit inside the GRAPH_TABLE definition.

Under the hood

EXPLAIN shows what actually happens.

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

tutorial=# explain SELECT *

FROM GRAPH_TABLE (social

MATCH (a IS person)-[IS knows]->

(b IS person)-[IS knows]->(c IS person)

WHERE a.id <> c.id

COLUMNS (a.name AS a, b.name AS via, c.name AS c)

)

ORDER BY a, c, via;

                              QUERY PLAN                                              

------------------------------------------------------------------

Sort  (cost=575.72..588.55 rows=5132 width=96)

  Sort Key: person.name, person_2.name, person_1.name

  ->  Hash Join  (cost=145.96..259.45 rows=5132 width=96)

        Hash Cond: (knows_1.b = person_2.id)

        Join Filter: (person.id <> person_2.id)

        ->  Hash Join  (cost=117.74..217.66 rows=5138 width=72)

            Hash Cond: (knows.b = person_1.id)

            ->  Hash Join  (cost=28.23..64.01 rows=2040 width=40)

                Hash Cond: (knows.a = person.id)

                ->  Seq Scan on knows  

(cost=0.00..30.40 rows=2040 width=8)

                ->  Hash  (cost=18.10..18.10 rows=810 width=36)

                    ->  Seq Scan on person  

(cost=0.00..18.10 rows=810 width=36)

            ->  Hash  (cost=64.01..64.01 rows=2040 width=44)

                ->  Hash Join  

(cost=28.23..64.01 rows=2040 width=44)

                    Hash Cond: (knows_1.a = person_1.id)

                    ->  Seq Scan on knows knows_1  

(cost=0.00..30.40 rows=2040 width=8)

                    ->  Hash  (cost=18.10..18.10 rows=810 width=36)

                        ->  Seq Scan on person person_1  

(cost=0.00..18.10 rows=810 width=36)

        ->  Hash  (cost=18.10..18.10 rows=810 width=36)

            ->  Seq Scan on person person_2  

(cost=0.00..18.10 rows=810 width=36)

(20 rows)

Crucially, no additional executor nodes appear in the plan. PostgreSQL rewrites the query behind the scenes, so the graph syntax is purely a more compact and readable way to express the joins between existing tables. CREATE PROPERTY GRAPH helps describe the data model in a form that enables that simplification.

What comes next

The next part of the series moves on to heterogeneous graphs and more complex data models in SQL/PGQ on PostgreSQL 19.