Back to Blog

Graph Queries in PostgreSQL: Recursive Traversal, Shortest Path, and Social Graphs

Implement graph queries in PostgreSQL without a dedicated graph database. Covers WITH RECURSIVE for tree and graph traversal, shortest path with BFS, cycle detection, social graph queries, and performance patterns.

Viprasol Tech Team
March 25, 2027
14 min read

Graph databases like Neo4j are purpose-built for connected data — but most teams already have PostgreSQL, and PostgreSQL handles a surprising range of graph queries well with WITH RECURSIVE. Social graphs, org charts, permission inheritance, dependency graphs, route finding — all achievable without adding another database.

This guide covers the patterns: tree traversal, graph BFS/DFS, shortest path, cycle detection, and the performance techniques that keep recursive queries from blowing up on large datasets.

Foundational Schema: Adjacency List

The adjacency list — a table of edges — is the universal representation for graphs in SQL:

-- Generic directed graph: nodes + edges
CREATE TABLE nodes (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  type        TEXT NOT NULL,    -- 'user', 'team', 'project', etc.
  label       TEXT NOT NULL,
  properties  JSONB NOT NULL DEFAULT '{}',
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE edges (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  from_node   UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
  to_node     UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
  edge_type   TEXT NOT NULL,    -- 'FOLLOWS', 'MANAGES', 'MEMBER_OF', 'DEPENDS_ON'
  weight      NUMERIC,          -- For weighted graphs (distances, costs)
  properties  JSONB NOT NULL DEFAULT '{}',
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),

  UNIQUE(from_node, to_node, edge_type)
);

-- Indexes for traversal performance
CREATE INDEX idx_edges_from  ON edges(from_node, edge_type);
CREATE INDEX idx_edges_to    ON edges(to_node, edge_type);
CREATE INDEX idx_edges_type  ON edges(edge_type);

For domain-specific graphs, specialised tables are cleaner:

-- Social graph: who follows whom
CREATE TABLE user_follows (
  follower_id UUID NOT NULL REFERENCES users(id),
  followee_id UUID NOT NULL REFERENCES users(id),
  followed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  PRIMARY KEY (follower_id, followee_id),
  CHECK (follower_id <> followee_id)
);

CREATE INDEX idx_follows_follower ON user_follows(follower_id);
CREATE INDEX idx_follows_followee ON user_follows(followee_id);

-- Org chart: reports_to hierarchy
CREATE TABLE org_structure (
  user_id     UUID PRIMARY KEY REFERENCES users(id),
  manager_id  UUID REFERENCES users(id),
  department  TEXT NOT NULL,
  title       TEXT NOT NULL
);

CREATE INDEX idx_org_manager ON org_structure(manager_id);

Tree Traversal: All Descendants

Find all users reporting (directly or indirectly) to a given manager:

WITH RECURSIVE subordinates AS (
  -- Base case: direct reports
  SELECT
    u.id,
    u.name,
    u.email,
    o.title,
    o.manager_id,
    1 AS depth,
    ARRAY[u.id] AS path  -- Track path to detect cycles
  FROM org_structure o
  JOIN users u ON u.id = o.user_id
  WHERE o.manager_id = $1  -- Root manager ID

  UNION ALL

  -- Recursive case: their reports
  SELECT
    u.id,
    u.name,
    u.email,
    o.title,
    o.manager_id,
    s.depth + 1,
    s.path || u.id
  FROM org_structure o
  JOIN users u ON u.id = o.user_id
  JOIN subordinates s ON o.manager_id = s.id
  WHERE
    u.id <> ALL(s.path)  -- Cycle guard
    AND s.depth < 10     -- Depth limit
)
SELECT
  id,
  name,
  email,
  title,
  depth,
  path
FROM subordinates
ORDER BY depth, name;

☁️ Is Your Cloud Costing Too Much?

Most teams overspend 30–40% on cloud — wrong instance types, no reserved pricing, bloated storage. We audit, right-size, and automate your infrastructure.

  • AWS, GCP, Azure certified engineers
  • Infrastructure as Code (Terraform, CDK)
  • Docker, Kubernetes, GitHub Actions CI/CD
  • Typical audit recovers $500–$3,000/month in savings

All Ancestors (Upward Traversal)

Find the full reporting chain above a given employee:

WITH RECURSIVE managers AS (
  -- Base: the employee themselves
  SELECT
    u.id,
    u.name,
    o.title,
    o.manager_id,
    0 AS depth
  FROM org_structure o
  JOIN users u ON u.id = o.user_id
  WHERE u.id = $1  -- Employee ID

  UNION ALL

  -- Recursive: walk up the tree
  SELECT
    u.id,
    u.name,
    o.title,
    o.manager_id,
    m.depth - 1
  FROM org_structure o
  JOIN users u ON u.id = o.user_id
  JOIN managers m ON o.user_id = m.manager_id
  WHERE o.manager_id IS NOT NULL
)
SELECT id, name, title, depth
FROM managers
WHERE depth < 0  -- Exclude the employee themselves
ORDER BY depth DESC;  -- Root manager first

Shortest Path: BFS

Find the shortest connection path between two users in a social graph:

-- BFS: shortest path from user A to user B
WITH RECURSIVE bfs AS (
  -- Base: start from source node
  SELECT
    follower_id AS from_node,
    followee_id AS to_node,
    ARRAY[follower_id, followee_id] AS path,
    1 AS depth
  FROM user_follows
  WHERE follower_id = $1  -- Source user

  UNION ALL

  -- Expand frontier one hop at a time
  SELECT
    bfs.to_node,
    f.followee_id,
    bfs.path || f.followee_id,
    bfs.depth + 1
  FROM bfs
  JOIN user_follows f ON f.follower_id = bfs.to_node
  WHERE
    f.followee_id <> ALL(bfs.path)  -- No revisiting
    AND bfs.depth < 6               -- 6 degrees of separation limit
    AND NOT EXISTS (                -- Stop once target found
      SELECT 1 FROM bfs b2
      WHERE b2.to_node = $2
    )
)
SELECT path, depth
FROM bfs
WHERE to_node = $2          -- Target user
ORDER BY depth ASC
LIMIT 1;                    -- Shortest path only

⚙️ DevOps Done Right — Zero Downtime, Full Automation

Ship faster without breaking things. We build CI/CD pipelines, monitoring stacks, and auto-scaling infrastructure that your team can actually maintain.

  • Staging + production environments with feature flags
  • Automated security scanning in the pipeline
  • Uptime monitoring + alerting + runbook automation
  • On-call support handover docs included

Degree of Connection

-- Check if two users are connected within N hops
WITH RECURSIVE reach AS (
  SELECT followee_id AS node, 1 AS hops, ARRAY[follower_id] AS visited
  FROM user_follows
  WHERE follower_id = $1

  UNION ALL

  SELECT f.followee_id, r.hops + 1, r.visited || f.follower_id
  FROM user_follows f
  JOIN reach r ON r.node = f.follower_id
  WHERE
    f.followee_id <> ALL(r.visited)
    AND r.hops < $3  -- max hops
)
SELECT
  CASE WHEN EXISTS (SELECT 1 FROM reach WHERE node = $2)
    THEN (SELECT MIN(hops) FROM reach WHERE node = $2)
    ELSE -1
  END AS degree_of_separation;

Common Connections (Friends of Friends)

-- Users followed by both $1 and $2 (mutual follows)
SELECT u.id, u.name, u.avatar_url
FROM user_follows f1
JOIN user_follows f2 ON f1.followee_id = f2.followee_id
JOIN users u ON u.id = f1.followee_id
WHERE f1.follower_id = $1
  AND f2.follower_id = $2
ORDER BY u.name;

-- People $1 might know: followed by people $1 follows (2nd degree)
SELECT
  u.id,
  u.name,
  COUNT(DISTINCT connector.id) AS mutual_connections
FROM user_follows f1                            -- $1 follows connector
JOIN user_follows f2 ON f2.follower_id = f1.followee_id  -- connector follows suggestion
JOIN users u ON u.id = f2.followee_id
JOIN users connector ON connector.id = f1.followee_id
WHERE f1.follower_id = $1
  AND f2.followee_id <> $1                      -- Not $1 themselves
  AND NOT EXISTS (                              -- $1 doesn't already follow them
    SELECT 1 FROM user_follows
    WHERE follower_id = $1 AND followee_id = f2.followee_id
  )
GROUP BY u.id, u.name
ORDER BY mutual_connections DESC
LIMIT 20;

Permission Inheritance Graph

-- Role permission graph: role inherits from parent roles
CREATE TABLE roles (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name       TEXT NOT NULL UNIQUE,
  parent_id  UUID REFERENCES roles(id)   -- role inheritance
);

CREATE TABLE role_permissions (
  role_id       UUID NOT NULL REFERENCES roles(id),
  permission    TEXT NOT NULL,
  PRIMARY KEY (role_id, permission)
);

-- All permissions for a role (including inherited)
WITH RECURSIVE role_hierarchy AS (
  SELECT id, name, parent_id
  FROM roles
  WHERE id = $1  -- Starting role

  UNION ALL

  SELECT r.id, r.name, r.parent_id
  FROM roles r
  JOIN role_hierarchy rh ON r.id = rh.parent_id
)
SELECT DISTINCT rp.permission
FROM role_hierarchy rh
JOIN role_permissions rp ON rp.role_id = rh.id
ORDER BY rp.permission;

Cycle Detection

-- Detect cycles in a directed graph (e.g., task dependencies)
CREATE TABLE task_dependencies (
  task_id        UUID NOT NULL REFERENCES tasks(id),
  depends_on_id  UUID NOT NULL REFERENCES tasks(id),
  PRIMARY KEY (task_id, depends_on_id),
  CHECK (task_id <> depends_on_id)
);

-- Would adding edge (new_task → new_dep) create a cycle?
-- Check if new_task is reachable FROM new_dep (if so, adding new_task→new_dep creates a cycle)
WITH RECURSIVE reachable AS (
  SELECT depends_on_id AS node, ARRAY[task_id] AS visited
  FROM task_dependencies
  WHERE task_id = $2  -- new_dep

  UNION ALL

  SELECT td.depends_on_id, r.visited || td.task_id
  FROM task_dependencies td
  JOIN reachable r ON td.task_id = r.node
  WHERE td.depends_on_id <> ALL(r.visited)
)
SELECT EXISTS (
  SELECT 1 FROM reachable WHERE node = $1  -- new_task
) AS would_create_cycle;

TypeScript Query Wrappers

// lib/graph/queries.ts
import { prisma } from "@/lib/prisma";

export async function getSubordinates(
  managerId: string,
  maxDepth = 10
): Promise<Array<{ id: string; name: string; title: string; depth: number }>> {
  return prisma.$queryRaw`
    WITH RECURSIVE subordinates AS (
      SELECT u.id, u.name, o.title, 1 AS depth, ARRAY[u.id::uuid] AS path
      FROM org_structure o
      JOIN users u ON u.id = o.user_id
      WHERE o.manager_id = ${managerId}::uuid

      UNION ALL

      SELECT u.id, u.name, o.title, s.depth + 1, s.path || u.id::uuid
      FROM org_structure o
      JOIN users u ON u.id = o.user_id
      JOIN subordinates s ON o.manager_id = s.id
      WHERE u.id <> ALL(s.path) AND s.depth < ${maxDepth}
    )
    SELECT id, name, title, depth FROM subordinates ORDER BY depth, name
  `;
}

export async function getShortestPath(
  fromUserId: string,
  toUserId: string,
  maxHops = 6
): Promise<string[] | null> {
  const rows = await prisma.$queryRaw<Array<{ path: string[] }>>`
    WITH RECURSIVE bfs AS (
      SELECT
        followee_id AS to_node,
        ARRAY[follower_id, followee_id]::uuid[] AS path,
        1 AS depth
      FROM user_follows
      WHERE follower_id = ${fromUserId}::uuid

      UNION ALL

      SELECT
        f.followee_id,
        bfs.path || f.followee_id::uuid,
        bfs.depth + 1
      FROM bfs
      JOIN user_follows f ON f.follower_id = bfs.to_node
      WHERE f.followee_id <> ALL(bfs.path) AND bfs.depth < ${maxHops}
    )
    SELECT path FROM bfs WHERE to_node = ${toUserId}::uuid
    ORDER BY depth LIMIT 1
  `;

  return rows[0]?.path ?? null;
}

export async function wouldCreateCycle(
  taskId: string,
  dependsOnId: string
): Promise<boolean> {
  const rows = await prisma.$queryRaw<Array<{ would_create_cycle: boolean }>>`
    WITH RECURSIVE reachable AS (
      SELECT depends_on_id AS node, ARRAY[task_id::uuid] AS visited
      FROM task_dependencies WHERE task_id = ${dependsOnId}::uuid

      UNION ALL

      SELECT td.depends_on_id, r.visited || td.task_id::uuid
      FROM task_dependencies td
      JOIN reachable r ON td.task_id = r.node
      WHERE td.depends_on_id <> ALL(r.visited)
    )
    SELECT EXISTS (SELECT 1 FROM reachable WHERE node = ${taskId}::uuid) AS would_create_cycle
  `;

  return rows[0]?.would_create_cycle ?? false;
}

Performance Patterns

-- Always index both directions of an edge table
CREATE INDEX idx_follows_from ON user_follows(follower_id);
CREATE INDEX idx_follows_to   ON user_follows(followee_id);

-- For weighted shortest path on large graphs, use materialized reach tables
CREATE MATERIALIZED VIEW user_2nd_degree AS
SELECT DISTINCT f1.follower_id AS user_id, f2.followee_id AS connected_to
FROM user_follows f1
JOIN user_follows f2 ON f1.followee_id = f2.follower_id
WHERE f1.follower_id <> f2.followee_id;

CREATE INDEX idx_2nd_degree ON user_2nd_degree(user_id);

-- Refresh when follow graph changes significantly
REFRESH MATERIALIZED VIEW CONCURRENTLY user_2nd_degree;

When to move to a graph database:

  • Graph has >10M edges and you need sub-100ms multi-hop traversal
  • Complex path algorithms (all shortest paths, betweenness centrality)
  • You're querying graph structure itself, not just node properties

PostgreSQL handles most social graph, org chart, and permission inheritance needs up to tens of millions of nodes efficiently.

Cost and Timeline Estimates

ScopeTeamTimelineCost Range
Org chart hierarchy queries1 dev1–2 days$300–600
Social graph (follows, connections, mutual)1 dev3–5 days$800–1,500
Full graph module (BFS, cycle detection, permissions)1–2 devs1–2 weeks$2,500–5,000
Large-scale graph with materialized views + Neo4j migration2 devs3–5 weeks$8,000–20,000

See Also


Working With Viprasol

Graph queries in PostgreSQL unlock powerful features — permission inheritance, social graphs, recommendation systems — without adding database complexity. Our team implements recursive CTE patterns with correct cycle guards, proper indexing strategies, and materialized view caching for large graphs.

What we deliver:

  • Adjacency list schema design for your domain
  • Recursive CTE queries: ancestors, descendants, BFS, shortest path
  • Cycle detection for dependency graphs
  • Materialized views for pre-computed second-degree connections
  • TypeScript query wrappers with Prisma raw queries

Talk to our team about your graph data requirements →

Or explore our cloud and database services.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need DevOps & Cloud Expertise?

Scale your infrastructure with confidence. AWS, GCP, Azure certified team.

Free consultation • No commitment • Response within 24 hours

Viprasol · Big Data & Analytics

Making sense of your data at scale?

Viprasol builds end-to-end big data analytics solutions — ETL pipelines, data warehouses on Snowflake or BigQuery, and self-service BI dashboards. One reliable source of truth for your entire organisation.