Postgres as a graph database
dylanpaulus.com
dylanpaulus.com
In our last benchmark aimed at analytical systems [2], we found that SQL queries using WITH RECURSIVE can work for expressing reachability and even weighted shortest path queries. However, formulating an efficient algorithm yields very complex SQL queries [3] and their execution requires a system with a sophisticated optimizer such as Umbra developed at TU Munich [4]. Industry SQL systems are not yet at this level but they may attain that sometime in the future.
Another direction to include graph queries in SQL is the upcoming SQL/PGQ (Property Graph Queries) extension. I'm involved in a project at CWI Amsterdam to incorporate this language into DuckDB [5].
[1] https://ldbcouncil.org/benchmarks/snb/
[2] https://www.vldb.org/pvldb/vol16/p877-szarnyas.pdf
[3] https://github.com/ldbc/ldbc_snb_bi/blob/main/umbra/queries/...
[5] https://www.cidrdb.org/cidr2023/slides/p66-wolde-slides.pdf
Take a look at RDFox, unlike postgres where you can only derive a column from another column once, you can't derive a column from a derived column, RDFox can derive unlimited columns or even objects through rules incrementally. Kind of like unlimited chaining of materialized views
https://github.com/thatdot/quine
Their claim to fame is progressive incremental computation - each node is an actor responding to events -- and I'm not sure how a relational db could do that and match the latencies. That usecase is pretty much pattern matching and forensics and stuff like that.
> We derive new iterative CTE variants from the simple loop-based, operational semantics of SQL:1999’s WITH RECURSIVE.
https://www.cidrdb.org/cidr2023/papers/p14-hirn.pdf
Quine uses Cypher so expressing path queries can be done with the concise Kleene-star syntax, e.g. (p1:Person)-[:knows*]-(p2:Person).
Materalize is getting support for WITH RECURSIVE and WITH MUTUALLY RECURSIVE (their own SQL extension that fixes some issues of WITH RECURSIVE):
I ported the example in this tutorial to SQLite here - now you can play with it in Datasette Lite: https://lite.datasette.io/?sql=https%3A%2F%2Fgist.githubuser...
Amusingly I ported the sample PostgreSQL code using the new ChatGPT "Browsing" alpha, with the following prompt:
> Read this tutorial and then output equivalent create table and insert statements for SQLite https://www.dylanpaulus.com/posts/postgres-is-a-graph-databa...
With the static database trick, combined with recursive graphs
For what it's worth, there's a lot of footguns with this approach. You need to be careful about infinite cycles and things like that. You also just do not get the ergonomics of using a query language that is meant for graph data. Once you get it all setup and experience the issues you will no doubt be able to build whatever you want but there's a learning curve.
In the end we switched to using Neo4j and were able to build new features a lot more quickly than we would've been able to on Postgres.
It's also worth mentioning that there are many ways to store graph data, and using an "adjacency list" like is being done here may or may not be the best way for your use case. Neo4j I think uses a different approach where nodes store info about edges they are connected to directly, so you don't even need to hit an index to follow an edge.
Not really, just never forget this: always use `UNION`, and never `UNION ALL`, in recursive queries!
Here's a small example:
create table node (
name varchar primary key
);
create table edge (
from_name varchar not null references node (name),
to_name varchar not null references node (name)
);
insert into node values
('Hawaii'),
('San Francisco'),
('New York'),
('Houston');
insert into edge values
('Hawaii', 'San Francisco'),
('San Francisco', 'New York'),
('San Francisco', 'Houston'),
('Houston', 'New York');
with recursive route (destination_name, path) as (
values ('Hawaii', 'Hawaii')
union
select to_name, route.path || ' -> ' || to_name
from route
join edge on route.destination_name = edge.from_name
)
select * from route;
That works as long as the graph is acyclic. If you add an edge from New York to Houston, say, then the query goes into an infinite loop.How would you do this after computing the transitive closure? And how would that avoid an infinite loop?
sqlite> .schema
CREATE TABLE edge (src text, dst text);
sqlite> with recursive paths (src, dst, len, path) as (
...> select src, dst, 0, src || '->' || dst from edge
...> union
...> select p.src, e.dst, p.len + 1, p.path || '->' || e.src from paths p join edge e on p.dst = e.src where p.len < (select count(*) from (select src from edge union select dst from edge))) select * from paths;
Hawaii|San Francisco|0|Hawaii->San Francisco
San Francisco|New York|0|San Francisco->New York
San Francisco|Houston|0|San Francisco->Houston
Houston|New York|0|Houston->New York
Houston|Hawaii|0|Houston->Hawaii
Hawaii|Houston|1|Hawaii->San Francisco->San Francisco
Hawaii|New York|1|Hawaii->San Francisco->San Francisco
San Francisco|Hawaii|1|San Francisco->Houston->Houston
San Francisco|New York|1|San Francisco->Houston->Houston
Houston|San Francisco|1|Houston->Hawaii->Hawaii
Hawaii|Hawaii|2|Hawaii->San Francisco->San Francisco->Houston
Hawaii|New York|2|Hawaii->San Francisco->San Francisco->Houston
San Francisco|San Francisco|2|San Francisco->Houston->Houston->Hawaii
Houston|Houston|2|Houston->Hawaii->Hawaii->San Francisco
Houston|New York|2|Houston->Hawaii->Hawaii->San Francisco
Hawaii|San Francisco|3|Hawaii->San Francisco->San Francisco->Houston->Hawaii
San Francisco|Houston|3|San Francisco->Houston->Houston->Hawaii->San Francisco
San Francisco|New York|3|San Francisco->Houston->Houston->Hawaii->San Francisco
Houston|Hawaii|3|Houston->Hawaii->Hawaii->San Francisco->Houston
Houston|New York|3|Houston->Hawaii->Hawaii->San Francisco->Houston
Hawaii|Houston|4|Hawaii->San Francisco->San Francisco->Houston->Hawaii->San Francisco
Hawaii|New York|4|Hawaii->San Francisco->San Francisco->Houston->Hawaii->San Francisco
San Francisco|Hawaii|4|San Francisco->Houston->Houston->Hawaii->San Francisco->Houston
San Francisco|New York|4|San Francisco->Houston->Houston->Hawaii->San Francisco->Houston
Houston|San Francisco|4|Houston->Hawaii->Hawaii->San Francisco->Houston->Hawaii
sqlite>I had a go at changing my query to only produce one route to each node, by excluding rows in the recursive step which go to a node for which we already have a destination, but i couldn't find a way to do it which PostgreSQL would accept, because you can't use the recursive table in an outer join or a subquery.
The PostgreSQL has some advice about dealing with cycles, but i haven't worked up the mental energy to absorb it:
https://www.postgresql.org/docs/14/queries-with.html#QUERIES...
The PG thing works trivially because you can use an array for the path and inspect the array in each iteration. That's harder to do in SQLite3, but... you could: just format a string like '%->' || src || '->%' then use it with `like()` to check if the path you're adding `src` to already has it.
Others, for example SQLite[1], seem to advocate using UNION ALL over UNION.
[1]: https://sqlite.org/lang_with.html#recursive_query_examples
eg: recursively generating the set of x := x + 1 won't terminate beacuse x goes to infinity, and you get infinite tuples. x := abs(x) is fine, since you get up to two tuples per input tuple (-1, and 1, for example).
Anyway, if you ask for the query to build up all paths (of unbounded length), it won't terminate. If you asked for shortest paths, (bounded by the number of nodes in the source database) it would.
I was struggling why aggregates were excluded so to speak, but I guess it's because they have to be repeatedly evaluated? Ie compute the sum, add it to the output, now you might have to recompute the sum? Perhaps I'm being dense here, left my thinking cap at home.
If you are comfortable fitting your data onto one box, then development speed is probably more important than other factors and I would just try a few databases out and especially pay attention to how good the libraries are in your programming language of choice. Neo4j for instance had high quality libraries in Java but the experience in Python was not as good.
If you have a lot of data or need next-level performance, I would start by doing some research on the various ways to store graph data and then pick a DB that supports the approach you want to take.
Graph database store true relationships.
The R in RDBMS and the concept of relationships by primary keys are lies (imo). They lead people away from what true relationships can be. Databases like Neo4j are not about doing any sort of PK or join or merge-before-query. Looking up by connection is the fastest way to find information. If your RDBMS had one table for phone numbers, one for email addresses, one for first names, one for last names, one for street, and so-on it would take huge amounts of time to query to find all the detail for a single person. With a graph database it takes a small amount of time to find the first bit of info (let’s say phone number since it’s easily sorted), and then every other bit of linked data is essentially free.
As someone who has wanted to love the graph dbs, keep with narrow focus top down queries where the index free adjacency lets you avoid touching the bulk of your data, and stay away from bulk queries that may need to visit everywhere multiple times, which is unfortunately where things get interesting.
You can get this book for free through their website. [1]
This have some good documentation[2], including this page "Transition from relational to graph database".[3]
[1] https://neo4j.com/graph-databases-book/
[2] https://neo4j.com/docs/getting-started/current/
[3] https://neo4j.com/docs/getting-started/current/appendix/grap...
Some differences between SQL and graph databases are covered at https://memgraph.com/blog/graph-database-vs-relational-datab....
Safest bet (knowing nothing else about your needs): Build at least three prototypes, preferably rapidly and in parallel. Only after doing so will you be able to ask the right questions that speak accurately to your specific needs and therefore be able to map actual requirements to the different graph databases.
I’d be curious what other experienced graph database people would say about their first bigger projects. Looking back, my first graph projects involved significant iteration and several restarts. And these were still necessary after having significant background knowledge about the algorithms, offerings, and trade offs.
There are many people in roles/orgs that don’t have the humility or organizational cover to admit that these early projects are largely experimental learning experiences.
> ...assume a large, "social media" scale system, to be safe.
This broad assumption is going to cost you.
Instead, I’d encourage you to reframe this assumption in more specific terms such as read, write patterns; need for consistency, availability; support expectations; your talent pool; and lots more. Estimates can be on a log10 scale: 1, 10, 100, 1000 and so on,
another good example using predicates - https://github.com/threatgrid/asami/wiki/2.-Introduction
Others have already pointed out the issues of cycles.
> The solution is to build and index denormalized n-th order relationship tables.
This sounds much more performant but also more difficult to maintain.
Obviously maintaining a flattened version is going to perform better in queries, but you're trading maintenance (write) time for query time. Or materialization time if you decide to do it lazily.
A->B
A->D
B->C
C->D
Postgres will walk both paths from A to D in the recursive CTE and then you can filter them afterwards to keep only the shortest.
You can use aggregate functions within the recursive CTE, so you can’t GROUP BY your starting node and stop once you find the first path. There isn’t a way to compare across multiple paths or iterations of the for-loop.
If I'm traversing ancestors (let's say) until I find one that doesn't satisfy a condition, it'll bail out then. I get that this doesn't serve all uses cases, but it isn't a small thing either.
I myself build transitive closure tables that I update incrementally as needed (sometimes in a deferred way), so that many of the recursive queries I do can be very fast, but I only build transitive closures for the smallest portion I can (basically group nesting).
What do you consider large ? We have a 12-year old telco application with a few million objects in Neo4J, doing fault impact calculations... Would PostgreSQL handle that easily nowadays ?
https://gist.github.com/simonw/c16ce01244760e186a3a0aa3fee04...
Then I ran that query again and it seems to return results in about 80ms:
https://lite.datasette.io/?sql=https://gist.github.com/simon...
Can you elaborate on this?
Does an n-th order relationship table contain all the nodes reachable from some node going through n edges?
And you'd have one such table for each integer in the range 2..n?
I've found recursive queries to be difficult to scale in real-world queries past a few million edge nodes. We've denormalized several tree relationships so that the edge is connected both to its parent and to the root of its tree in several cases.
I'm using recursive algorithm on Postgres to find trees in a graph, but only where I'm specificaly interested in all of the nodes.
But I've tried to push it to its limits (using PostGIS, my nodes being land plots that were connected to each others — the biggest query used joins, lateral, recCTE, windows… you name it) and ran into many issues: can't branch out early, hard to optimize, no support for complex queries (writing anything beyond BFS and DFS is a pita). Yet, in the end I accepted the struggle (and the long queries) because it was so nice to work with my data directly where it was kept :)
Fun was had and headaches too. The biggest speedup I got was computing the graph online (creating a node/vertices tables from geometries) and then doing the recursive CTE on that table.
[1] https://engineering.fb.com/2013/06/25/core-data/tao-the-powe...
There are generally two families in the graph database world: those which use underlying traditional tables of nodes and many-to-many edges; and index-free adjacency which just means each node in the graph knows the memory address of its connections (other side of the edges).
Distributed graphs necessarily end up using the former because it’s difficult if not impossible for a node to know the memory address of its connection when that crosses a physical boundary. So typically index-free adjacency graphs have a master-slave setup with multiple read replicas but a single one to write to.
So with a “native graph” you don’t rely on potentially expensive join operations to find neighbors of neighbors and can traverse complex paths easily.
There are a LOT of use cases that will fit the capabilities and performance characteristics.
Our data is a directed graph (an organization tree) so the materialization code (accomplished via triggers) isn't too bad.
The result is that instead of WITH RECURSIVE we just join against our closure table which is in the form `(organization, ancestor)`. Example type of query: "Is Alice an administrator in any of Bob's organizations or in any ancestor organizations", and if that's true it would give them the ability to perform X administrative action. Actually queries are more nuanced but the core Hard Thing :tm: is traversing the graph and it's really easy with our closure table.
Our closure table itself contains "evidence" that enables correctness constraints. So a row can only exist if a) it represents a direct parentage (ancestor is the direct parent) or b) ancestor is a parent of my ancestor. This is all enforced via foreign keys, so if the foreign keys are set up correctly an errant row isn't possible.
Behind all this is a proptest (property based testing is awesome for stuff like this). We generate arbitrary org forests, perform random mutations (changing parents, deleting orgs, gaining children, etc) and then assert that the closures table is accurate. We have very high confidence that the data is correct because of this test suite.
Native graph databases like Neo4J use index-free adjacency. This means query durations are proportional to the amount of the graph searched, -not- the size of the data stored.
https://neo4j.com/blog/native-vs-non-native-graph-technology...
For large graphs, or deeply recursive queries, you need a native graph database to get reasonable performance.
1-star, hate, will quit before using it again
There are some other fun related applications like graph rewriting using SQL, etc.
MiniLitelog: Easy Breezy SQLite Datalog - https://www.philipzucker.com/tiny-sqlite-datalog/
Schema is like this
id
type: string (or enum)
data: jsonb
indexed: jsonb (but this one gets a GIN index)
toId: nullable foreign key to this same table
fromId: nullable foreign key to this same table
createdAt: date
updatedAt: date
So if I had a post with a comment on it, the data would look something like this {
id: '1111',
type: 'users',
data: { name: 'John Doe' },
}
{
id: '2222',
type: 'users',
data: { name: 'Jane Doe' },
}
{
id: '3333',
type: 'posts',
data: { text: 'Nice day today' },
}
{
id: '4444',
type: 'comments',
data: { text: 'For you, maybe. Raining here' },
}
And now for the edges: {
id: '5555',
type: '(posts < users)',
toId: '3333',
fromId: '1111',
}
{
id: '6666',
type: '(comments < users)',
toId: '4444',
fromId: '2222',
}
{
id: '7777',
type: '(posts < comments)',
toId: '3333',
fromId: '4444',
}
So then, for example, if I have a post, and I want to find the comments on it, I search for toId = <my post id>, type = '(posts < comments)'.Presumably, all nodes would have nulls in both fromId and toId, but the schema doesn't enforce that. The schema also allows linking edges to other edges.
Don't you think this is a bit too much flexibility if the intention is to model a graph?
I've since worked on SpiceDB[1] which scales much better by taking the traditional design approach for graph databases and only treating Postgres as triple-store. IME, there's no short-cut: if you need a graph, you probably want to use a database optimized for graph access patterns. Most general-purpose graph databases are full of optimizations for common traversals that are uncommon operations in relation databases.
1. Both the tables (Nodes and Edges) MUST HAVE one more column as "label" and then you must create partitions based on distinct values of "label" column. This greatly helps queries to run faster as search plan is narrowed when you include it in where clause and PG knows partitions to hit.
2. Instead of one big fat primary key column in each table use, consider having different uniques indexes on each partition.
3. The EDGES table can afford to have "data" column of data type TEXT or perhaps JSONB. PG saves it in different data disk layout and compression works great here. We used it to cache the result for previous/next nodes data or compute the result for many extra hops which are required on frequent basis.
4. Use PG procedures to hide the complexity of reusable DFS/BFS queries.
That said, I've had so many jobs where the team decides to fit our whole data model into a fully recursive graph, and it's been a huge mistake every time regardless of whether they use Postgres or a dedicated graph DB. Just because you can do it (you always can!) doesn't mean you should. Start with just a traditional n-hierarchy schema and only look at modeling things as a graph if you're sure it's necessary. Usually it's not. Sometimes you'll have a mostly non-graph schema with a couple of graph tables for the things that really need that.
https://www.postgresql.org/docs/current/queries-with.html#QU... the docs themselves do have information about it.
Any questions?
It's not clear how one might make SQL syntax for recursive queries simpler. Clearly we can have syntactic sugar for "dereferencing" relationships, so one could say `SELECT child-->name FROM parent_child WHERE ...;`, and we could have aggregation `SELECT array_agg(child-->name) FROM parent_child WHERE ...;`, and `SELECT child--->name FROM parent_child WHERE ...;` to say "recurse all the way through the child relation", but if you want more than one column then things get tricky.
So, the path key could look something like: bob.ted.lisa.bill (obviously, use ids instead of names here)
Then you can index this in various ways to make LIKE queries fast.
Want do know anyone that rolls up to ted?
select * from blah where path like 'bob.ted%'
Or, how many people report to ted, but aren't under lisa? select count(*) from blah where path like 'bob.ted%' and path not like 'bob.ted.lisa%'
And something a bit more complex that would typically be considered the domain of a graph database, like "What are the average number of direct reports each manager in my organization has?" select name, avg((select count(*) from blah as sub where path like '%' || name || '%' and (array_position(string_to_array(path,'.'), name) = ((array_position(string_to_array(path,'.'), sub.name) - 1))
So, even with that degree of complexity, you're still avoiding recursion, though you might be getting into O(n^2) territory if you don't have an index that will handle '%' || ... || '%' well. You can expand on this with various techniques depending on the application.It's certainly no replacement for a graph database, but for basic stuff it's very fast, handles huge volumes, and doesn't rely on any sort of recursion at the database layer (unless you materialize in a trigger or something).
The actual materialization code can be tricky, one technique to deal with that is to use the author's technique or something similar to have a recursive CTE in a view. When a graph change is made, you can scope the change based on the path's hierarchy and update the paths from the recursive view, that way you only hit the performance penalty at write time, not read.
in my particular case i don't actually have a lot of data so i'm not looking for performance, just want to save developer time.
I'm interested in what the fundamental difference is in those graph dbs because rdbms seem so close I don't see why it isn't just provided as an extra layer on top.
As described in the article, any graph can already be built with an rbms, so why for example does neo4j need to be standalone and can't possibly be a layer on top of postgres to leverage existing relationships? Any good articles on that?
https://neo4j.com/blog/native-vs-non-native-graph-technology...
In native graph databases like Neo4J query times are proportional to the amount of the graph searched, rather than increasing with the overall size of the data. That's the clincher.
In addition to that, Postgres is difficult as a computational platform - the execution strategy for recursive queries is not necessarily intuitive, and their performance is hard to assess (for example: how soon will you run out of "stack"?)
https://harmonious-mermaid-c4d794.netlify.app/got (sorry not mobile friendly but functional)
The issues were on deletes. This demo is read only so it works well with a PS backend. My experience with your CS has been amazing despite these issues!!
https://dimagi.github.io/django-cte/#recursive-common-table-...
If Postgres added it, almost all of my interest in Materialize (the database, not the technique) would vanish immediately.
Also calling a graph database a serialized tree structure seems quite a stretch.
https://en.wikipedia.org/wiki/Hierarchical_and_recursive_que...
It's much simpler to query in native graph database land (though it's not exactly SQL, it's pretty close to be honest). And there are performance / reliability implications of forcing postgres into graph land.
"However, recursive SQL queries can be expected to perform comparably for 'find immediate descendants' queries, and much faster for other depth search queries, and so are the faster option for databases which provide them, such as PostgreSQL,..."
The size of the table (# of edges) won't actually matter *that* much performance wise with btree indexes. The performance will mostly depend on how deep you want to go with your recursion. The more nodes you check per query, the slower it'll be. Luckily PG will keep the frequently accessed nodes and index pages in the buffer pool, so it'll perform close enough to an in-memory graph database for most use cases that access the same data often - while not requiring your whole dataset to fit into memory all the time.
MongoDB's current graph search mechanism works the same way.
I think actually if you are using incremental ids and you don't delete edges, you could try BRIN indexes for the next/previous node columns. This could still get you pretty good performance with much lower storage space requirements than a "traditional" index, so you could theoretically store a pretty large graph (thinking billions of edges) with minimal disk space.
You can get O(1) performance on similar queries using a different storage strategy (by storing edges as pointers to the other nodes in memory) but SQL is not the right tool for that, you would be better off with a true graph DB.
Some scalability and performance metrics would be helpful here.
Anyone know of good docs for this sort of pattern with SQL that addresses those two common tasks?
as well as chapter 27 of SQL for Smarties, 5th
isn't ISO working on an graph extension to regular SQL? Can't seem to google it correctly
The recursive query is cool, but how is the performance for graph queries compared to a DB optimized for this use case ?
But I think Postgres is likely to get these improvements in a few years too.
Meta may have had a good reason not to choose a 'native' graph DB.
So, is this basically running transitive closure on the database during query? it would be expensive.
Do not blame the tool if you feel it doesn't fit your niche. Blame yourself first, and try to use it the right way.
Is there a definite architectural pattern to store hierarchial comments?