postgres has graphs now
postgres 19 adds CREATE PROPERTY GRAPH and GRAPH_TABLE. what a property graph compiles to, what it costs, and where the plain view equivalent stops working.
postgres 19 ships CREATE PROPERTY GRAPH. peter eisentraut and ashutosh bapat's patch landed in march 2026 after two years in review, and implements SQL/PGQ, part 16 of the SQL:2023 standard. beta 1 came out earlier this month, so you can run everything below today.
it is not a graph database. there is no new storage engine and nothing new on disk. a property graph in postgres is a read-only view over tables you already have, and it stores nothing of its own.
so it buys you no performance, and most of what follows is about what it does buy, which is narrower than the announcements suggest.
the schema you already have
the tables are the ones any multi-tenant app ends up with.
create table companies (
id int primary key,
name text not null,
tier text not null
);
create table users (
id int primary key,
name text not null,
email text not null
);
create table memberships (
user_id int not null references users(id),
company_id int not null references companies(id),
role text not null,
since date not null,
primary key (user_id, company_id)
);
create table projects (
id int primary key,
company_id int not null references companies(id),
name text not null,
visibility text not null
);
create table collaborators (
user_id int not null references users(id),
project_id int not null references projects(id),
capability text not null,
primary key (user_id, project_id)
);
with a handful of rows in them:
id | name | email
----+-------+-------------------------
1 | ada | ada@northwind.example
2 | linus | linus@northwind.example
3 | grace | grace@beacon.example
4 | rob | rob@northwind.example
user_id | company_id | role | since
---------+------------+------------+------------
1 | 1 | owner | 2021-03-01
2 | 1 | engineer | 2022-07-14
4 | 1 | engineer | 2024-01-08
3 | 2 | owner | 2020-11-02
3 | 1 | contractor | 2025-06-30
id | name | tier
----+----------------+------------
1 | northwind labs | enterprise
2 | beacon systems | startup
id | company_id | name | visibility
----+------------+-----------+------------
1 | 1 | compiler | internal
2 | 1 | scheduler | internal
3 | 2 | ledger | private
user_id | project_id | capability
---------+------------+------------
4 | 3 | review
1 | 3 | admin
grace is in two companies. rob and ada have direct grants on ledger, a project belonging to a company neither of them works for. the exceptions matter more than the happy path, because access rules are mostly exceptions.
the query you write against this is unremarkable:
select u.name, m.role, c.name, pr.name
from users u
join memberships m on m.user_id = u.id
join companies c on c.id = m.company_id
join projects pr on pr.company_id = c.id;
the same tables, declared as a property graph
CREATE PROPERTY GRAPH names some tables as vertices, some as edges, and says which columns connect them.
CREATE PROPERTY GRAPH org
VERTEX TABLES (
users LABEL person PROPERTIES (id, name, email),
companies LABEL company PROPERTIES (id, name, tier),
projects LABEL project PROPERTIES (id, name, visibility)
)
EDGE TABLES (
memberships AS works_at
SOURCE users DESTINATION companies
LABEL works_at PROPERTIES (role, since),
projects AS owns
SOURCE KEY (company_id) REFERENCES companies (id)
DESTINATION KEY (id) REFERENCES projects (id)
LABEL owns NO PROPERTIES
);
two things in there matter more than the syntax.
memberships needed no key spec at all. the foreign keys already say which column points at users and which points at companies, so postgres infers SOURCE and DESTINATION from the existing constraints. the join table was already an edge table. it just did not have the word for it.
projects appears twice, once as a vertex and once as an edge. a company owning a project is not stored anywhere except as projects.company_id, so the edge is the projects table joined to itself through that column. a table can be a node and a relationship at the same time.
querying it uses GRAPH_TABLE, which is a table function you put in FROM:
SELECT * FROM GRAPH_TABLE (org
MATCH (p IS person)-[m IS works_at]->(c IS company)-[IS owns]->(pr IS project)
COLUMNS (p.name AS person, m.role, c.name AS company, pr.name AS project)
) ORDER BY person, project;
person | role | company | project
--------+------------+----------------+-----------
ada | owner | northwind labs | compiler
ada | owner | northwind labs | scheduler
grace | contractor | northwind labs | compiler
grace | owner | beacon systems | ledger
grace | contractor | northwind labs | scheduler
linus | engineer | northwind labs | compiler
linus | engineer | northwind labs | scheduler
rob | engineer | northwind labs | compiler
rob | engineer | northwind labs | scheduler
(9 rows)
the MATCH pattern reads left to right as a shape: a person, an outgoing works_at edge, a company, an outgoing owns edge, a project. COLUMNS picks what falls out. everything outside the parentheses is ordinary SQL, so ORDER BY, GROUP BY, joins against other tables and CTEs all work the way you expect.
GRAPH_TABLE compiles to the joins you would have written
the release notes will not tell you what this costs, so here is the plan for the graph query:
Hash Join
Hash Cond: (memberships.user_id = users.id)
-> Hash Join
Hash Cond: (memberships.company_id = companies.id)
-> Seq Scan on memberships
-> Hash
-> Hash Join
Hash Cond: (projects.company_id = companies.id)
-> Seq Scan on projects
-> Hash
-> Seq Scan on companies
-> Hash
-> Seq Scan on users
and here is the plan for the hand written join:
Hash Join
Hash Cond: (m.user_id = u.id)
-> Hash Join
Hash Cond: (m.company_id = c.id)
-> Seq Scan on memberships m
-> Hash
-> Hash Join
Hash Cond: (pr.company_id = c.id)
-> Seq Scan on projects pr
-> Hash
-> Seq Scan on companies c
-> Hash
-> Seq Scan on users u
the same plan, down to the join order and the join methods. the only textual difference is that the hand written query has table aliases and the rewritten one does not. GRAPH_TABLE is rewritten into a relational query before the planner ever sees it, the same way a view is. property graphs even get their own relkind in pg_class (g), sitting next to tables and views.
there is no speedup here, and there is not going to be one, because the current implementation does fixed-depth patterns only. a three-hop pattern is three joins.
what you get instead is that the query says what it means. the join version encodes "people can see projects through the company they work for" as a chain of equality predicates, and that sentence exists nowhere except in the head of whoever wrote it. the graph version puts the sentence in the schema, once, and every query gets to refer to it by name.
that is a readability argument, and on its own it does not justify the ceremony. a named view would do the same job.
two property graphs, one set of rows
nothing says a table gets one interpretation. define a second graph over the exact same five tables, reading them as an authorization model instead of an org chart:
CREATE PROPERTY GRAPH access
VERTEX TABLES (
users LABEL principal PROPERTIES (id, name),
companies LABEL tenant PROPERTIES (id, name),
projects LABEL resource PROPERTIES (id, name, visibility)
)
EDGE TABLES (
memberships AS member_of
SOURCE users DESTINATION companies
LABEL permits PROPERTIES (
role AS via,
(role IN ('owner', 'engineer')) AS can_write
),
collaborators AS invited_to
SOURCE users DESTINATION projects
LABEL permits PROPERTIES (
capability AS via,
(capability = 'admin') AS can_write
),
projects AS scopes
SOURCE KEY (company_id) REFERENCES companies (id)
DESTINATION KEY (id) REFERENCES projects (id)
LABEL scopes NO PROPERTIES
);
the row (3, 1, 'contractor') in memberships did not move, change, or get copied. in org it is grace's employment at northwind. in access it is a permission grant whose via is 'contractor' and whose can_write is false. same bytes, two readings, and postgres holds both at once.
two details in access carry most of the weight.
the first is that properties are expressions, not columns. can_write does not exist in any table. it is role IN ('owner','engineer') evaluated per row, exposed under a name that belongs to the access vocabulary rather than the storage vocabulary.
the second is that two different tables carry the same label. memberships and collaborators have different columns, different primary keys, and different destination tables. but both mean "this principal was granted something", so both get LABEL permits with matching property names. one pattern then spans both:
SELECT * FROM GRAPH_TABLE (access
MATCH (p IS principal)-[g IS permits]->(x)
COLUMNS (p.name AS principal, g.via, x.name AS target)
) ORDER BY principal, target;
principal | via | target
-----------+------------+----------------
ada | admin | ledger
ada | owner | northwind labs
grace | owner | beacon systems
grace | contractor | northwind labs
linus | engineer | northwind labs
rob | review | ledger
rob | engineer | northwind labs
(7 rows)
seven grants from two tables, out of one pattern whose right hand side is not labelled. x is a company for some of those rows and a project for others, and x.name resolves against whichever table the row came from.
postgres checks that agreement. labels sharing a name must have the same property names, and properties sharing a name must have the same type, or the CREATE fails.
the query that returns a route
the question worth asking of an access model is "who can reach the ledger project, and how":
SELECT * FROM GRAPH_TABLE (access
MATCH (p IS principal)-[g IS permits]->(r IS resource WHERE r.name = 'ledger')
COLUMNS (p.name AS principal, g.via, g.can_write, 'direct' AS route)
)
UNION ALL
SELECT * FROM GRAPH_TABLE (access
MATCH (p IS principal)-[g IS permits]->(t IS tenant)
-[IS scopes]->(r IS resource WHERE r.name = 'ledger')
COLUMNS (p.name AS principal, g.via, g.can_write, 'tenant' AS route)
)
ORDER BY principal;
principal | via | can_write | route
-----------+--------+-----------+--------
ada | admin | t | direct
grace | owner | t | tenant
rob | review | f | direct
(3 rows)
three people can reach ledger and no two of them get there the same way. ada was invited directly with admin. grace owns the company that owns the project. rob was invited with review only, which is why his can_write is false while ada's is true, off the same permits label.
the same rows come out of two four-table joins glued together with UNION ALL. that version works, but the two branches look unrelated to each other, and "review is weaker than admin" ends up as a bare comparison in a select list with nothing naming it.
the graph version returns the same answer with the reason attached. rob can read ledger because he was invited to it with review capability, and that shows up in via and route instead of collapsing to true. you can get there with the joins too. the difference is that the reason is named by the model rather than assembled per query.
i have a longer complaint about authorization systems that hand back a bare boolean and throw the reason away, which is the next post.
what SQL/PGQ will not do yet
four limits are worth knowing before you plan anything around it.
variable-length paths do not work. the syntax parses and is then refused:
ERROR: element pattern quantifier is not supported
no ->{1,3}, no ->+. this is the big one. transitive closure over a role hierarchy or a nested group tree still needs a recursive CTE, and that is exactly the workload people expect a graph feature to cover. it is expected in a later release.
it is read only, and behaves like a view:
ERROR: cannot open relation "org"
LINE 1: insert into org values (1);
^
DETAIL: This operation is not supported for property graphs.
writes go to the base tables, so the graph cannot drift out of sync with them. that is the whole difference from syncing rows into a separate graph store, and it is the same reason i keep reaching for postgres instead of another datastore.
the dependencies are real. the graph registers a catalog dependency on every column it names:
ERROR: cannot drop column email of table users because other objects depend on it
DETAIL: property email of label person of vertex users of property graph org depends on column email of table users
HINT: Use DROP ... CASCADE to drop the dependent objects too.
a graph defined for one reporting query will now block an unrelated migration. that is correct, and it puts property graphs in the same operational category as views and generated columns.
access is security invoker. permissions are checked against the user running the query, on the base tables, rather than against the graph owner, so the graph grants nothing on its own. a security definer variant is not implemented yet.
property graphs versus views
most of what a property graph does, a view already does. naming a relationship, hiding a join, exposing a computed column, all of that is CREATE VIEW with more typing. if you read the feature as "nicer joins" you are reading it correctly and you should skip it.
i tried to reproduce the shared label with a view, expecting it to be the thing views could not do. it mostly is not. this returns the same seven rows:
create view permits as
select user_id, company_id as target_id, 'tenant' as target_kind, role as via,
role in ('owner','engineer') as can_write
from memberships
union all
select user_id, project_id, 'resource', capability, capability = 'admin'
from collaborators;
the difference shows up when you want the target's name. the view has flattened two different kinds of thing into an untyped target_id plus a discriminator, so getting back to a typed row costs a conditional join per kind, in every query that needs one:
select u.name as principal, p.via, coalesce(c.name, pr.name) as target
from users u
join permits p on p.user_id = u.id
left join companies c on p.target_kind = 'tenant' and c.id = p.target_id
left join projects pr on p.target_kind = 'resource' and pr.id = p.target_id
order by principal, target;
that produces identical output to the GRAPH_TABLE version above. it is not unreasonable code. but the union and the type discrimination are decisions the view makes once and every caller inherits, whereas the graph leaves x as a real vertex and lets each query decide how far to walk. adding a third grant table means editing the view and rechecking its callers, or adding one line to the graph.
whether that is worth a second schema object is not obvious to me. it is a second vocabulary to keep current, and the dependency errors above mean it will block migrations that have nothing to do with graphs. i have not run this in anything real, nobody has, it is a beta of a feature whose most requested capability is missing. so treat the above as what the thing does rather than a recommendation.
what i do think is true is that the join has been the only way to say "these rows are related", and the kind of relation has always lived in application code or in nobody's head. this is the first postgres mechanism that puts it in the schema. whether that turns out to matter depends on things nobody has data on yet.
if you want to try it:
nix shell nixpkgs#postgresql_19
initdb -D ./data
pg_ctl -D ./data -l ./log start
psql -d postgres
sources
- CREATE PROPERTY GRAPH, postgres 19 docs
- 5.15. property graphs, postgres 19 docs
- the commit, march 2026
- the commitfest entry, two years of review from first submission in february 2024