PostgreSQL Graph Queries with MATCH: 3 Practical Traversal Patterns
DEV Community

PostgreSQL Graph Queries with MATCH: 3 Practical Traversal Patterns

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: User Project Task 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. Top comments (0)

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.