Extending the model to multiple entity types

Real applications rarely fit into two tables. The sample schema here stores friendship alongside work relations: the knows edge table links people to each other, while works_at connects people to companies.

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

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

-- People (six rows, three cities)

CREATE TABLE person (

    id   int  PRIMARY KEY,

    name text NOT NULL,

    age   int  NOT NULL,

    city text NOT NULL

);

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');

-- "Knows" edges between people (nine rows, two mutual pairs)

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 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);

-- Companies (the new vertex type for this post)

CREATE TABLE company (

    id       int  PRIMARY KEY,

    name     text NOT NULL,

    industry text NOT NULL

);

INSERT INTO company VALUES

    (10, 'Acme',    'manufacturing'),

    (20, 'Globex',  'finance'),

    (30, 'Initech', 'software');

-- "Works at" edges (the new edge type for this post)

CREATE TABLE works_at (

    pid   int  NOT NULL REFERENCES person(id),

    cid   int  NOT NULL REFERENCES company(id),

    role   text NOT NULL,

    since int  NOT NULL,

    PRIMARY KEY (pid, cid)

);

INSERT INTO works_at VALUES

    (1, 10, 'engineer',   2017),  -- Alice  @ Acme

    (2, 10, 'engineer',   2019),  -- Bob    @ Acme

    (3, 20, 'sales',      2015),  -- Carol  @ Globex

    (4, 20, 'engineer',   2020),  -- Dan    @ Globex

    (5, 30, 'cto',        2010),  -- Eve    @ Initech

    (6, 30, 'engineer',   2022),  -- Frank  @ Initech

    (1, 30, 'consultant', 2024);  -- Alice ALSO at Initech

The property graph built on top of this data declares two vertex tables — one for people, one for companies — and exposes a handful of properties for later use.

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

-- The property graph: two vertex labels, two edge labels

CREATE PROPERTY GRAPH company_social

    VERTEX TABLES (

        person KEY (id)

LABEL person  

PROPERTIES (id, name, age, city),

        company KEY (id)

LABEL company

PROPERTIES (id, name, industry)

    )

    EDGE TABLES (

        knows

            SOURCE      KEY (a)   REFERENCES person  (id)

            DESTINATION KEY (b)   REFERENCES person  (id)

            LABEL knows PROPERTIES (since),

        works_at

            SOURCE      KEY (pid) REFERENCES person  (id)

            DESTINATION KEY (cid) REFERENCES company (id)

            LABEL works_at PROPERTIES (role, since)

    );

The interesting part is on the edge side. Because knows joins entries within one table and works_at joins entries across two, the graph spans both tables and mixes private and professional relationships in a single structure.

Traversing a heterogeneous path

With the graph defined, a natural question is where Alice's friends work. The query filters Alice inside GRAPH_TABLE — the alias me, with a WHERE in the MATCH clause — then traverses from person to person through the edges, and from there to companies.

1

2

3

4

5

6

7

8

9

10

11

12

13

SELECT *

FROM   GRAPH_TABLE (company_social

MATCH (me IS person WHERE me.name = 'Alice')

-[IS knows]->(friend IS person)

-[IS works_at]->(co IS company)

COLUMNS (friend.name AS friend, co.name AS company)

)

ORDER BY friend, company;

friend | company

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

Bob    | Acme

Carol  | Globex

(2 rows)

The equivalent in plain SQL is already non-trivial, which is the point of the exercise:

1

2

3

4

5

6

7

SELECT  pf.name AS friend, c.name AS company

FROM    person pm

JOIN knows    k ON k.a   = pm.id

JOIN person  pf ON pf.id = k.b

JOIN works_at w ON w.pid = pf.id

JOIN company  c ON c.id  = w.cid

WHERE pm.name = 'Alice';

Joining graph results back into SQL

Finding two co-workers who know each other would standardly be expressed as multiple comma-separated patterns inside one MATCH. PostgreSQL 19 does not support that syntax yet; the implementation has not caught up with the standard.

1

2

3

4

5

SELECT  *

FROM    GRAPH_TABLE (company_social

MATCH (a IS person)-[IS works_at]->(c IS company)

<-[IS works_at]-(b IS person),

...

The workaround matters more than the original query. Run one GRAPH_TABLE per pattern and join the two results in ordinary SQL:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

SELECT  m.me, m.via, m.coworker, w.company

FROM    GRAPH_TABLE (company_social

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

[IS knows]->(c IS person)

     WHERE   a.id <> c.id

     COLUMNS (a.id AS aid, c.id AS cid, a.name AS me,

b.name AS via, c.name AS coworker)

) m

JOIN    GRAPH_TABLE (company_social

     MATCH (x IS person)-[IS works_at]->(co IS company)

<-[IS works_at]-(y IS person)

     WHERE   x.id <> y.id

     COLUMNS (x.id AS xid, y.id AS yid, co.name AS company)

) w ON w.xid = m.aid AND w.yid = m.cid

ORDER BY me, coworker;

1

2

3

4

  me   |  via  | coworker | company

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

Alice | Carol | Bob      | Acme

Eve   | Frank | Alice    | Initech

Because GRAPH_TABLE produces a table, the pattern generalizes: project the join keys out of COLUMNS and the rest of SQL is available as usual.

Pitfalls to plan around

Anonymous edges union across labels

Where a graph has a single edge label, (a)->(b) is unambiguous. In a multi-label graph such as company_social, dropping [IS knows] silently merges every edge table:

1

2

3

4

5

6

SELECT  count(*)

FROM    GRAPH_TABLE (company_social

     MATCH (a)->(b) COLUMNS (a.name, b.name));

count

-------

    16

Nine knows edges and seven works_at edges, combined. The plan shows an Append over two separate join trees, which confirms the union:

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

                                              QUERY PLAN                                              

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

Aggregate  (cost=237.71..237.72 rows=1 width=8)

   ->  Append  (cost=56.45..229.78 rows=3170 width=0)

         ->  Subquery Scan on unnamed_subquery  (cost=56.45..118.03 rows=2040 width=0)

               ->  Hash Join  (cost=56.45..97.63 rows=2040 width=64)

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

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

                           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=4)

                                 ->  Seq Scan on person  (cost=0.00..18.10 rows=810 width=4)

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

                           ->  Seq Scan on person person_1  (cost=0.00..18.10 rows=810 width=4)

         ->  Subquery Scan on unnamed_subquery_1  (cost=57.35..95.90 rows=1130 width=0)

               ->  Hash Join  (cost=57.35..84.60 rows=1130 width=64)

                     Hash Cond: (works_at.cid = company.id)

                     ->  Hash Join  (cost=28.23..52.50 rows=1130 width=4)

                           Hash Cond: (works_at.pid = person_2.id)

                           ->  Seq Scan on works_at  (cost=0.00..21.30 rows=1130 width=8)

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

                                 ->  Seq Scan on person person_2  (cost=0.00..18.10 rows=810 width=4)

                     ->  Hash  (cost=18.50..18.50 rows=850 width=4)

                           ->  Seq Scan on company  (cost=0.00..18.50 rows=850 width=4)

(22 rows)

Edge labels should be named explicitly in any graph backed by more than one edge table.

No variable-length patterns

Quantified paths such as (a)-[IS knows]->{1,3}(b) are not available yet and remain on the roadmap. Until then, the options are to unroll the hop counts explicitly — one GRAPH_TABLE per count, combined with UNION — or to fall back to WITH RECURSIVE over the underlying edge table.