
PostgreSQL Graph Queries with MATCH: 3 Patterns You'll Actually Use
If your graph-shaped data already lives in PostgreSQL, moving it into another database just to traverse relationships can create more architecture than value.
In Part 1, I covered the core idea behind pg_igraph.
This time, I want to stay close to product code: three MATCH patterns that solve real traversal
problems without pushing graph logic into your application layer.
No graph theory.
Just queries you can ship.
Why this matters
A lot of teams implement graph traversal in application code:
- query nodes,
- query related nodes,
- merge results,
- deduplicate,
- repeat.
It works at first, then turns into brittle glue logic.
MATCH lets you express traversal intent directly in SQL-like form, where it's easier to test,
review, and optimize.
Minimal model
Let's use a tiny domain:
UserProjectTask
With relationships:
(:User)-[:OWNS]->(:Project)(:Project)-[:HAS_TASK]->(:Task)(:User)-[:COLLABORATES_WITH]->(:User)
Visual shape:
(User)-[:OWNS]->(Project)-[:HAS_TASK]->(Task)
\
\-[:COLLABORATES_WITH]->(User)
Pattern 1 β Direct relationship lookup
Question: Which projects does this user own?
MATCH (u:User)-[:OWNS]->(p:Project)
WHERE u.id = $user_id
RETURN p;
Why it's useful:
- clear query intent,
- no app-side traversal loop,
- easy to wrap in a reusable repository/query function.
Pattern 2 β Two-hop traversal
Question: Which tasks are reachable from a user through owned projects?
MATCH (u:User)-[:OWNS]->(p:Project)-[:HAS_TASK]->(t:Task)
WHERE u.id = $user_id
RETURN t;
This replaces a common backend anti-pattern:
- fetch project IDs first,
- run second query for tasks,
- merge in code.
One traversal query is usually simpler to reason about than two procedural fetch steps.
Pattern 3 β Bounded variable-length expansion
Question: Show collaborators up to 2 hops away (excluding self).
MATCH (u:User)-[:COLLABORATES_WITH*1..2]->(c:User)
WHERE u.id = $user_id
AND c.id <> u.id
RETURN DISTINCT c;
Why bounded depth matters:
- protects against accidental full-graph explosions,
- makes product behavior explicit ("up to 2 hops"),
- gives more predictable latency.
Production notes (the part that saves pain)
1) Always constrain labels and predicates early
Don't start with broad patterns when graph size is growing.
2) Prefer bounded depth unless you truly need unbounded traversal
*1..2 or *1..3 is often enough for user-facing features.
3) Use DISTINCT intentionally
Multi-path traversals can duplicate nodes fast.
4) Benchmark on realistic graph shape, not toy data
Real skew (hub nodes, uneven degree) changes performance characteristics.
5) Treat graph queries as domain API
Name them, test them, and keep them versioned like endpoints.
When pg_igraph is a strong fit
This approach works especially well when:
- your source-of-truth data is already in PostgreSQL,
- graph traversal is part of product workflows,
- you want one operational surface (auth, backups, observability, deploy).
If your workload is mostly deep graph analytics at large scale, a dedicated graph engine can still be the better tool.
But for many product teams, graph-in-Postgres is the shortest path from prototype to production.
What I'd build next
If this is useful, Part 3 can cover:
- path-query anti-patterns,
- indexing strategy and profiling workflow,
- CI tests for graph traversal correctness.