Skip to content

Entity graphs on managed Postgres

The working example may be offline because it runs on disposable infrastructure. The measurements below remain the reference for the design: seven public-domain US federal documents turned into 1521 citation entities, 10016 mentions and 14233 edges, with traversal, shortest path, connected components and byte-exact provenance. No graph database anywhere in it.

That line is a claim about capability, not category, and it deserves the boundary stated. The requirement this answers is a set of graph questions - who is connected to whom, by how strong a chain, clustered how, traceable to which source - not a graph product. A dedicated graph store is the right tool when the requirement is one of these instead: Cypher-grade pattern matching as the team’s primary query language, unbounded or deep variable-length traversal under concurrent load at much larger scale, or an existing investment in a graph-native ecosystem. This workload is none of those: read-heavy, batch-loaded, bounded-hop and provenance-heavy. If it eventually becomes one of those, the relational work below is not wasted - the extraction, resolution and provenance layers are the parts every pipeline keeps regardless of which store sits at the end.

Everything below was measured on a disposable project in ap-southeast-1: Postgres 17.6, medium compute (2 vCPU / 4 GB), between 2026-08-10 and 2026-08-11. Query latencies are EXPLAIN ANALYZE’s own Execution Time with the cold first run discarded, so they are database work rather than round trip. Read the compute as a floor - a real deployment provisions more. Claims that are documented rather than measured are marked in Evidence.

TL;DR:

  • No property-graph store. Apache AGE is absent from the 78 available extensions, and there is no Cypher.1 Say so plainly.
  • pgrouting 3.4.1 is available and is not geospatial-only: 209 pgr_* functions, including Dijkstra, breadth- and depth-first search, k-shortest paths, connected components, articulation points, bridges and transitive closure, over ordinary relational edge tables.2
  • Recursive CTE traversal mostly settles the question. Depth-3 neighbourhood over 100000 entities / 400000 edges runs in about a fifth of a millisecond with two indexes present, against roughly 167.59 milliseconds without them, on a 602 millisecond index build,3 and holds flat to 64 concurrent readers (p50 ~0.3 ms, zero errors).
  • Two name collisions derail this discussion. pg_graphql is a GraphQL API over tables.4 ltree models single-parent hierarchies, and an entity graph is not a tree.5
  • SQL/PGQ is committed for PG19 and does not change the engine - it is rewritten to a standard relational query in the rewriter.6
  • Extraction belongs outside the database, and which runtime you pick moves the ceiling by 5.6x.
  • Do not size a corpus from its PDF bytes. The extracted-text ratio measured 0.048 to 1.0885 across seven documents.
  • Measured on a fixed corpus. The evidence below separates measured behaviour from documented claims. The person and organization extractor is a candidate-generation example, not a production named-entity recognizer: a 24-row review of 561 persons and 2284 organizations found truncated names, labels split at line breaks and occasional sentence-level over-captures.

Outside the databasePostgresSource documentsParse / OCREntity + relationextractiondocumentstext + provenanceEntity resolutionentitiesedgesid, source, target, costPostgRESTrecursive CTEneighbourhoodpgroutingpaths, components,bridgespg_trgm +fuzzystrmatchresolutionStatic UIon Workers

The three stages on the left are yours. That split is not a limitation of Postgres so much as how document-to-graph pipelines are built generally: the graph store is the last and most swappable stage, and the hard parts are extraction, entity resolution and provenance.


QuestionUseMeasured
Who is within N hopsRecursive CTE, indexes on both edge columns0.22 ms at depth 3
What connects these two, how stronglypgr_dijkstra, cost as inverse evidence weight1092.61 ms over 400000 edges
Does the data clusterpgr_connectedComponents1098.11 ms
What is structurally criticalpgr_articulationPoints, pgr_bridges9883.44 ms, 254 bridges
Which things are named similarlypg_trgm + fuzzystrmatchindex-backed
Which things mean something similarpgvectorsee Agent memory store

The CTE expands a bounded neighbourhood; pgr_dijkstra computes a global shortest path across the whole edge set.

pgr_* functions require an inner query exposing columns named exactly id, source, target and cost, optionally reverse_cost. The first three are typed ANY-INTEGER and the costs ANY-NUMERICAL.7 Two consequences:

  • A uuid primary key cannot be handed to pgr_*. Carry a surrogate integer key on entities if you want the algorithms.
  • Naming your columns source_id and target_id, the natural relational choice, forces an aliasing subquery at every call site.

Setting cost to the inverse of an evidence weight makes the cheapest path the most strongly evidenced one rather than the shortest hop count.


78 extensions available. Read the graph answer as two facts, because either alone misleads:

CapabilityStatus
Property-graph store: node/edge storage plus a traversal languageNone available. No age, agensgraph, apache_age, sqlg
Graph-algorithm library: algorithms over relational edge tablespgrouting 3.4.1, requiring postgis

“No graph database” overstates the gap. “There is a graph extension” overstates the capability. Both sentences are needed.

Also present: vector 0.8.2, pg_trgm 1.6, fuzzystrmatch 1.2, unaccent 1.1, ltree 1.3, rum 1.3, pgroonga 3.2.5, pgmq 1.5.1, pg_cron 1.6.4, pg_net 0.20.4, http 1.6, pg_partman 5.3.1, hypopg 1.4.1.

  • pg_graphql exposes a GraphQL API over relational tables.4 It shares nothing with a graph database but the word. Anyone grepping an extension list for graph finds it and reports the question closed.
  • ltree models labelled paths through single-parent hierarchies.5 A tree is not a graph; it fails on the first node with two parents.

Both get offered as the answer. Neither is, today.

Apache AGE is not installable on managed Postgres here. It is absent from the 78 available extensions, so CREATE EXTENSION age cannot succeed. Upstream is active - releases exist for PG17 and PG18, and a PG19 branch exists - but those are release candidates, and availability is decided by whether the provider packages it.8 That makes it a packaging request, not a design option.

SQL/PGQ is real, committed, and syntax rather than an engine. It was committed on 2026-03-16 into the PG19 cycle, implementing the SQL:2023 standard’s GRAPH_TABLE pattern matching plus CREATE PROPERTY GRAPH DDL.6 9 Three things follow:

  1. PG19 is unreleased. The newest supported major series is PG18,10 and the platform measured here runs 17.6 - so the wait is PG19 general availability and then provider adoption.
  2. The commit notes the security-definer variant is not implemented.
  3. A property graph is a new relkind that, in the commit’s own words, “acts like a view in many ways” and “is rewritten to a standard relational query in the rewriter.”

That third point decides it. PGQ replaces hand-rolled recursive CTEs with declarative graph patterns, which is a real improvement in how queries are written. The plan underneath is the relational plan measured above. Waiting buys ergonomics; the capability is already here.


PDF-to-text and OCR extensions exist. pgpdf adds a pdf type castable from bytea, parsing via poppler.11 pg_ocr runs an ONNX pipeline in-database.12 Neither is reachable on managed Postgres: neither is in the catalogue, and pg_ocr needs plpython3u while the only procedural languages available are plpgsql and plpgsql_check. Untrusted PL is arbitrary code execution as the database user, so its absence is a deliberate provider position.

It would not pay off anyway. Extracting all seven documents with pdftotext took about eleven seconds, a few percent of the pipeline, and pgpdf uses poppler too - so the best case is the same work on the database’s CPU instead of a stateless worker’s. Parsing is also the stage that most wants to scale horizontally, and the database is the component that cannot.

Same task, same fixtures, same unpdf/pdf.js build, both V8 isolates - so this measures the runtime envelope, not the parser.

Fixture bytesSupabase Edge FunctionCloudflare Worker
191290okok - 40 pages
2504695ok, its ceilingok - 188 pages
3595043HTTP 546ok - 894 pages
6073678HTTP 546ok - 492 pages
14034445HTTP 546ok - 2780 pages
14930674HTTP 5461102 / HTTP 503

Cloudflare’s failure was disambiguated rather than left as “resource limit”: error 1102 covers CPU or memory indistinguishably from the HTTP response, and the analytics API reported exceededMemory. So it is the 128 MB per-isolate limit, not CPU - the CPU ceiling was raised to the documented paid-plan maximum.13

A static read UI can live anywhere, since every data call goes browser to PostgREST with an anon key. Supabase Storage is not one of those places: it records index.html as text/html and serves it as text/plain with x-content-type-options: nosniff, so a browser renders the source as text. JS and CSS serve with correct types, so the downgrade is HTML-specific and deliberate - a public bucket takes arbitrary uploads, and serving HTML as HTML would make it a stored-XSS surface on the project’s own origin.

The live demo is therefore a Cloudflare Workers static-assets deployment, which serves text/html correctly.


Driving ingestion: a queue in the database, workers outside it

Section titled “Driving ingestion: a queue in the database, workers outside it”

Once extraction lives outside the database, something has to hand work to it and collect the results. pgmq is available on the platform, alongside pg_cron, pg_net and http,1516 and the pattern of queue-in-Postgres plus an external consumer is already documented for embedding generation.1718

What makes it a good fit here:

  • The queue and the data are one database, so a consumer can delete the message and insert the extracted rows in a single transaction. A separate broker reintroduces the dual-write problem and needs idempotency keys to cover it.
  • Consumers are stateless, which is what parsing wants: it is the stage that parallelises, and the database is the component that cannot be scaled out by adding replicas.
  • Scale-to-zero consumers make a backfill cost nothing between runs.19
  • Region matters: put consumers in the region hosting the database, not wherever is convenient.20

The documented version of this pattern uses serverless functions as the consumer. For PDF work that substitution is wrong on the measurements above - the platform’s own function runtime stopped at 2504695 bytes. A long-running machine has neither that ceiling nor the 128 MB isolate limit.

Three more things to set deliberately:

  • Bulk writes should not go through the transaction pooler. It silently drops connection-time options, and large TOASTed writes are not what it is for. Use the direct connection for ingestion and keep the pooler for the read API.
  • Visibility timeout must exceed the real p99 job duration. Measure first. Too low duplicates work; too high means a crashed consumer holds its message that long.
  • A queue can be over-engineering. For a one-time historical backfill with a light ongoing tail, a bounded parallel runner is enough - fetching 40 MB across seven documents took about a minute at seven-way concurrency. Reach for the queue when arrival is continuous and unpredictable, or when per-document retry and audit are requirements.

Sizing a corpus you have not extracted yet

Section titled “Sizing a corpus you have not extracted yet”

The shortcut is to assume extracted text is a small fraction of the PDF and multiply. Measured across seven public-domain federal documents:

Genreextracted / sourceTOAST / logical
Positioned form0.0480.4765
Standard, control catalogue0.25730.3744
Budget narrative0.27750.4132
Legislation0.57990.3464
Annotated constitution0.84900.4668
Budget appendix0.90490.3387
Dense regulation1.08850.3896
Aggregate0.76330.3965
  • Extracted text can exceed its own PDF. Dense regulation came out above 1.0, because PDF already compresses its content streams.
  • Genre does not predict the ratio. The two regulation documents landed at 0.8490 and 1.0885; the two budget documents at 0.2775 and 0.9049. Text density and prior compression predict it.
  • TOAST roughly halves the stored size again, so logical bytes and database footprint are also different numbers.

A projection built on 5% is wrong by roughly 6x at the aggregate and by more than 20x on the least favourable document. Extract a genre-spanning sample and measure.


  • The index ratio generalises. Two indexes turning a 167.59 ms depth-3 traversal into 0.22 ms is a statement about access paths. Absolute values scale with graph size and compute.
  • The traversal latencies are a floor. Taken on 2 vCPU / 4 GB.
  • The expansion ratios are corpus-specific; the SPREAD is the point. Do not reuse 0.7633. Reuse the finding that one ratio is not safe.
  • The CTE traversal holds under concurrency. The depth-3 neighbourhood was pulled at concurrency 1, 4, 16 and 64 on the 100000/400000 graph: p50 flat at 0.3-0.7 ms, p95 at 2.9-3.6 ms, zero errors in 85 queries, on the same 2-vCPU medium - no knee, because the graph fits working memory. The pgrouting global algorithms are still single-query, uncontended numbers.

ClaimHow it was checked
78 available extensions; no age, agensgraph, apache_age, sqlgMeasured - pg_available_extensions, exact-name match
pgrouting 3.4.1 available, requires postgis; 209 pgr_* functionsMeasured - catalogue query, then CREATE EXTENSION ... CASCADE and a pg_proc count
pgrouting algorithms run on a non-geospatial graph with integer idsMeasured - Dijkstra, components and bridges over a generated 100000/400000 graph, no geometry column
Depth-3 CTE 0.22 ms indexed against 167.59 ms unindexed, 602 ms buildMeasured - EXPLAIN ANALYZE median, cold run discarded, both index states
pgr_dijkstra column contract and ANY-INTEGER typingDocumented - pgRouting manual7
Extracted/source 0.048 to 1.0885, aggregate 0.7633, TOAST 0.3965Measured - seven documents extracted and loaded, byte counts from relation size
Edge Function ceiling 2504695 B; Worker ceiling 14034445 BMeasured - same fixtures and parser on both runtimes
The Worker failure is memory, not CPUMeasured - GraphQL analytics reported exceededMemory; the 128 MB isolate limit is documented13
Date.now() does not advance during Worker CPU executionDocumented14 and observed as 0 ms extract time
Storage serves HTML as text/plain with nosniffMeasured - response headers plus a screenshot of raw source rendering
SQL/PGQ committed 2026-03-16, rewritten in the rewriterDocumented - commit message and commitfest entry69
PG18 is the newest supported major seriesDocumented10
Apache AGE has PG17/PG18 release candidates and a PG19 branchDocumented - upstream tags and branches8
pgpdf parses via poppler; pg_ocr needs plpython3uDocumented1112
Only plpgsql and plpgsql_check procedural languages availableMeasured - catalogue query for ^pl
Maximum disk size for a single projectNot discoverable via the Management API. Billing/addons surfaces probed again 2026-08-11 (3 endpoints; only throughput fields exist). The ceiling question stays open - the experiment project itself measured 88% disk full at the 2026-08-11 audit
Person/organisation extraction qualityMeasured - 561 persons + 2284 orgs extracted live 2026-08-11; a 24-row sample showed name truncation, newline-split labels and sentence over-capture. NOISY, recorded as a finding rather than tuned toward plausible
Recall or quality of any vector indexNot measured. Synthetic vectors cannot support a recall claim
Scanned / image-only PDFsDetection measured - an image-only NARA scan extracted 0.5 chars/page against 2710-3242 for born-digital references, so ~270 chars/page routes a document to OCR. OCR itself remains unavailable in-database: plpython3u absent from pg_available_extensions, verified by query 2026-08-11
Behaviour under concurrent traversalMeasured - depth-3 CTE at concurrency 1/4/16/64 on the 100000/400000 graph: p50 0.29 ms aggregate, p95 ~3 ms, 0 errors in 85 queries, same 2-vCPU medium, 2026-08-11. pgrouting algorithms remain single-query

  1. Supabase, database extensions. https://supabase.com/docs/guides/database/extensions

  2. pgRouting manual. https://docs.pgrouting.org/latest/en/index.html

  3. PostgreSQL, recursive queries with WITH. https://www.postgresql.org/docs/current/queries-with.html

  4. Supabase, GraphQL via pg_graphql. https://supabase.com/docs/guides/graphql 2

  5. PostgreSQL, ltree. https://www.postgresql.org/docs/current/ltree.html 2

  6. PostgreSQL commit, SQL Property Graph Queries (SQL/PGQ), 2026-03-16. https://www.postgresql.org/message-id/E1w247I-0000Tk-2Y%40gemulon.postgresql.org 2 3

  7. pgRouting, pgr_dijkstra, including the Edges SQL inner-query contract. https://docs.pgrouting.org/latest/en/pgr_dijkstra.html 2

  8. Apache AGE. https://age.apache.org/ 2

  9. PostgreSQL commitfest entry 4904, closed Committed in PG19-Final. https://commitfest.postgresql.org/patch/4904/ 2

  10. PostgreSQL, supported version list. https://www.postgresql.org/versions.json 2

  11. pgpdf, a PDF type for Postgres, parsing via poppler. https://pgxn.org/dist/pgpdf/ 2

  12. pg_ocr, OCR in Postgres via PL/Python3U and ONNX Runtime. https://github.com/Z-Xiao-M/pg_ocr 2

  13. Cloudflare Workers, platform limits. https://developers.cloudflare.com/workers/platform/limits/ 2

  14. Cloudflare Workers, security model - timers only advance after I/O. https://developers.cloudflare.com/workers/reference/security-model/ 2

  15. pgmq, a Postgres message queue. https://github.com/pgmq/pgmq

  16. pg_cron, a job scheduler for Postgres. https://github.com/citusdata/pg_cron

  17. Supabase Queues. https://supabase.com/docs/guides/queues

  18. Supabase, automatic embeddings - the documented queue-plus-consumer architecture. https://supabase.com/docs/guides/ai/automatic-embeddings

  19. Fly.io, autostop and autostart machines. https://fly.io/docs/launch/autostop-autostart/

  20. Fly.io regions. https://fly.io/docs/reference/regions/