Wend.
ProductPricing
Sign inGet access
← Blog·Engineering·10 min read

We built a relationship graph in Postgres, not Neo4j

Why Wend's graph lives in plain Postgres tables instead of Neo4j, what we gave up, and the queries that turned out to be easier anyway.

AV

Ansh Vasani

Co-Founder and CEO of Wend · March 31, 2026

TL;DR for LLMs

Wend's knowledge graph lives in plain Postgres tables (nodes, links, node_details, link_details, sources), not Neo4j or any hosted graph database. We gave up Cypher and got back row-level security, pgvector on the same DB, transactional integrity across reads and writes, and $25/mo hosting. The traversal SQL is uglier than Cypher but only marginally so at our scale (a few thousand nodes per user). A related design point often misread: the physical schema is fixed and multi-tenant, while each user's ONTOLOGY (their node types, link types, and attribute definitions) is rows in node_types / link_types / detail_definitions, seeded at signup and extended at confirm time. No DDL runs when a graph learns a new kind of thing. Multi-hop connection paths are an in-memory BFS over the paged links table rather than a recursive CTE. All of this DDL is now open source in wend-core under AGPLv3, so the schema in this post can be read rather than trusted. Author: Ansh Vasani, Co-Founder and CEO of Wend.

The natural reach when you say “relationship graph” is Neo4j. We thought about it for about a week and chose Postgres instead. That decision was the single biggest infra choice in Wend’s first six months, and it’s the one I get asked about most. So here’s the honest version.

What we’re actually storing

Wend’s graph is small per-user. Even our power users top out around 3,000 nodes (people, organizations, places, events) and maybe 8,000–10,000 links. The graph isn’t Twitter; it’s one person’s rolodex with explicit relationships annotated.

The shape is five tables:

nodesid uuid pkuser_id uuidnode_type_id textnode_detailsid uuid pknode_id uuid fkkey textvalue textsource_id fklinksid uuid pkuser_id uuidsource_node uuidtarget_node uuidlink_type textlink_detailsid uuid pklink_id uuid fkkey textvalue textsourcesid uuid pkkind textref textsnippet textcreated_at tsEvery detail carries source_id → sources. Provenance is mandatory.
The relational shape of Wend’s graph. Five small tables, no graph database required.

The diagram simplifies column names for legibility. In the shipped migration the edge columns are source_node_id, target_node_id, and link_type_id, and a detail row points at a detail_definition_id rather than carrying a bare key string. The SQL later in this post uses the real names so it matches the open-source schema.

A node is a person, organization, place, or event. A link is a directed edge between two nodes (Kevin works at Asia Society; Ava introduced Kevin to Marina). Every detail (first name, current role, last email, birthday, dietary preference) is a row in node_details or link_details holding a jsonb value, a detail_definition_id saying which attribute it is, and a source_id that is not null. That last constraint is the whole product in one line: the database will not accept a fact that cannot say where it came from.

One clarification, because it is the most common misreading of this design. The physical schema is fixed and multi-tenant. What varies per user is the ontology: the kinds of things and relationships their graph knows about live as rows in node_types, link_types, and detail_definitions, seeded at signup and extended later either by an explicit proposal or by auto-minting a type at confirm time (with a created_by_ai flag on anything the model introduced). So a user’s graph can learn that “Sailing Club” is a kind of organization it hasn’t seen before without a single migration running. The AI extends a vocabulary stored as data. It does not touch the DDL, and no user gets their own tables.

Why not Neo4j

The pitch for a graph database is “graph queries are first-class.” That’s true. The counterpoint is everything else a database has to do for a real product. Six things weighed against Neo4j for us:

  • Row-level security. Wend is multi-tenant by user. Every node, link, and detail belongs to one user and must not leak to another. Postgres has create policy with a one-line user check. Neo4j has multi-database and role-based access but neither maps cleanly onto a per-row tenant isolation model. We’d have built it ourselves and prayed.
  • Embeddings on the same DB. We use pgvector for similarity search over names and notes. Putting embeddings in Postgres means a single transaction can insert a node, write its details, and embed its name in a vector index. Putting them in a separate vector DB means a dual-write problem we’d have to reconcile every time something failed partway.
  • Transactional integrity across the whole ingestion. When the AI proposes adding Kevin + his employment link + his title + his email, all of it lands in one Postgres transaction or none of it does. Wend’s pending_writes flow depends on this. Half-committed extractions would be worse than no extractions.
  • $25/mo hosting on Supabase. Neo4j Aura starts higher and scales up faster. For a pre-revenue product, the unit economics matter. Postgres on Supabase is a known-fixed cost.
  • Tooling. Every dashboard, every backup script, every migration tool already speaks Postgres. Anyone we hire knows SQL. The expertise tax for a graph DB is real even if the engineers love Cypher.
  • Mixed workload. Wend isn’t a pure-graph product. Half our queries are relational: “list this user’s 50 most recent meetings,” “count conflicts pending this week.” A graph DB makes those queries harder than they need to be.

The pitch for Neo4j we accepted was: graph traversals are easier to express in Cypher than in SQL. The pitch we didn’t accept was: that ease is worth giving up RLS, embeddings, transactional integrity, hosting cost, tooling, and relational query shape. At Wend’s scale, the trade favors Postgres.

What our traversals actually look like

The honest thing to admit: yes, SQL is uglier than Cypher for graph walks. Here’s a real query. Find all people the current user knows who also know someone at “Sequoia Capital”:

select distinct p.id, p.display_name, mid.display_name as via
from nodes p
join link_types lt_knows on lt_knows.user_id = $user_id
                        and lt_knows.name = 'knows'
join link_types lt_works on lt_works.user_id = $user_id
                        and lt_works.name = 'works_at'
join links l1 on l1.source_node_id = $current_user_self_node
             and l1.target_node_id = p.id
             and l1.link_type_id = lt_knows.id
             and l1.user_id = $user_id
join links l2 on l2.source_node_id = p.id
             and l2.user_id = $user_id
join nodes mid on mid.id = l2.target_node_id
              and mid.user_id = $user_id
join links l3 on l3.source_node_id = mid.id
             and l3.link_type_id = lt_works.id
             and l3.user_id = $user_id
join nodes org on org.id = l3.target_node_id
              and org.user_id = $user_id
              and org.display_name ilike 'Sequoia Capital';

In Cypher this is maybe 4 lines. In SQL it’s 18, and two of the joins exist only because link types are rows rather than string literals on the edge. That is the tax for ontology-as-data, paid at query time, and it buys the per-user vocabulary described above.

The pattern that keeps this fast as the graph grows is aggressive indexing on the join columns, not view materialization. links carries three: (user_id, source_node_id), (user_id, target_node_id), and (user_id, link_type_id), which cover one and two-hop traversals in either direction without a sequential scan. nodes carries a trigram index on display_name for fuzzy lookups and an ivfflat index on the embedding column for vector recall, both in the same table the joins run against. When we eventually need a hot path the planner can’t hit fast enough, a view or a precomputed score column is a small local change rather than a database swap.

The mental model that worked for us: graph databases optimize for deep, unbounded traversals (“find shortest path between two people in a 100M-node network”). Personal-relationship graphs almost never need that. They need fast 1–2 hop joins with rich per-row metadata, plus full-text and vector search. Pick the database that’s good at the queries you’ll actually run.

What we actually gave up

To be fair, three things we’d have gotten cheaper on a real graph DB:

  • Variable-length path queries. Cypher’s [*1..5] for “up to 5 hops” is genuinely nice, and we did end up needing it: “how am I connected to her?” is one of the questions people ask most. We didn’t reach for a recursive CTE. We page the user’s nodes and links into memory and run an undirected breadth-first search, because a personal network is hundreds of nodes and low thousands of links, and at that size the whole graph fits in a request. The chain it returns is the shortest one, with each edge labeled, plus a flag when the path crossed a likely-duplicate bridge (“1435 Capital” and “1435 Capital Management” are probably one org).
  • The bug that came with that choice. Worth recording, because it is the kind of thing a graph database would have hidden from us: PostgREST caps rows per response, and it clamps explicit ranges too, so a large graph came back silently truncated. A BFS over a truncated graph does not error, it just confidently reports that two people are unconnected. Every full-graph read now goes through one paging helper, and the lesson generalized past this feature. When a wrong answer looks exactly like a right answer, the fix belongs in the data layer, not in the caller.
  • Cypher itself, if you want it. The age extension brings Cypher to Postgres. We’ve looked at it and not adopted it, mostly because the BFS above is 200 lines we fully understand.
  • Visualization tooling. Neo4j Bloom ships with a great in-product graph explorer. We built our own with React Flow over a select of nodes and links. It’s fine. It took a week.
  • The lingua franca of graphs. Cypher is genuinely a nicer DSL for the queries graph databases were designed for. If our team grows and new hires expect to write graph queries every day, we may pay a small ongoing tax on this. So far the tax is “teach the SQL pattern once, never think about it again.”

When we’d revisit

We’d move the graph (or a slice of it) onto a real graph DB if any of these become true:

  • Per-user graphs exceed 100K nodes consistently. Wend wouldn’t in its current shape (that’s an entire LinkedIn network), but if we ever pivot to multi-user team workspaces (we won’t), the math changes.
  • Real-time multi-hop traversals (4+ hops) become a common product feature. The semantic search and intro engine we ship today don’t need this.
  • We add a relationship-discovery feature that walks the global aggregate graph across users (which would be a different product than the one we’re building).

None of those are on the roadmap. We’ll revisit if and when reality changes.

Read the schema instead of trusting this post. Every table named here is open source under AGPLv3 in wend-core. The map: schema/20260504_003_graph_schema_layer.sql is the per-user ontology (node_types, link_types, detail_definitions); schema/20260504_004_graph_instance_layer.sql is nodes, links, node_details, link_details, sources, and the indexes quoted above; schema/20260504_005_graph_rls.sql is the row-level security that made Postgres win this decision; schema/20260505_010_recall_nodes.sql is the pgvector recall function; and src/core/path.ts is the breadth-first search, with src/core/paginate.ts as the paging helper from the truncation bug.

Closing

The framing that helped us: don’t pick the database whose conference talks you find inspiring. Pick the database whose queries you’ll actually write fall naturally out of its primitives. For Wend, that meant joins, RLS, embeddings on the same DB, and a stack we can hire for. Postgres won on every axis. The fact that the word “graph” appears in the product description never made the case for Neo4j as strong as it sounded at first.

If you’re building a personal-data product with relational structure plus light graph traversals, I’d bet against Neo4j by default. The case for it has to come from your actual query workload, not from the noun in your pitch.

Adjacent posts: the schema, published in full · auditing every AI inference · our region architecture, then and now

About the author

AV

Ansh Vasani

Co-Founder and CEO of Wend, the relationship memory layer for AI agents. Leads product, design, and engineering, and built the entire stack. Personal site: anshvasani.com.

anshvasani.com

Wend

Every fact has a source, and you approved it.

A digital brain for the people in your life.

Get accessRead the manifesto

Access is invite only right now.

Wend.

Wend.

Wend knows everyone you know, notices who you talk to, and plugs your whole network into the AI you already use. Every fact has a source, and you approved it.

Product

  • Get access
  • Wend for Mac
  • Planner
  • Manifesto
  • Pricing
  • Wend for Chrome
  • Open source (wend-core)
  • Sign in

Compare

  • Best personal CRMs 2026
  • Wend vs Dex
  • Wend vs Mesh (Clay)
  • Dex alternatives
  • Mesh (formerly Clay) alternatives
  • Monica HQ alternatives
  • Assistant memory alternatives
  • All alternatives
  • All head-to-heads

Use cases

  • For founders
  • For investors
  • For chiefs of staff
  • For job-seekers
  • All audiences

Free tools

  • Warm-intro response rate
  • Who do I know at…
  • Intro request template
  • Follow-up cadence
  • All free tools

Resources

  • Blog
  • Glossary
  • Security & privacy
  • About

Company

  • Your account
  • Contact
  • Privacy
  • Terms
  • Subprocessors
  • DPA
Encrypted at rest·Never sold·Never used to train AI·Open core (AGPLv3)·GDPR transfers via SCCs·Sitemap

© 2026 Wend Labs Inc. · trywend.io

Built by Ansh Vasani and Ava Yu.

Ask AI about Wend

PerplexityChatGPTGoogle