← Back to Blog
PostgreSQL Graph Queries with MATCH: 3 Patterns You'll Actually Use

PostgreSQL Graph Queries with MATCH: 3 Patterns You'll Actually Use

Published on 3 min readLedgyX Team

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:

  1. query nodes,
  2. query related nodes,
  3. merge results,
  4. deduplicate,
  5. 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.