Skip to content

Comparison ​

graphpg serves a GraphQL API and a REST API (with an OpenAPI 3.1 description) from the same PostgreSQL schema. This page compares each side with the tools you would otherwise choose: PostGraphile and Hasura for GraphQL, PostgREST for REST. It lists what each one does so you can choose; it is not a ranking. The other tools are mature and, in several areas, broader than graphpg.

The columns for other tools reflect their documented behaviour at the time of writing (October 2026; PostgREST from docs.postgrest.org, see sources) and may be out of date. Check their documentation for the version you would use. Where graphpg has no equivalent, the table says so.

Compared with other GraphQL tools ​

At a glance ​

graphpgPostGraphile v5 (Grafast)Hasura (v2 / v3 DDN)
RuntimeRust, single binaryNode.js library and CLIHaskell (v2); Rust (v3)
Data sourcesOne PostgreSQL database, and what it reaches through foreign data wrappers (other databases, files)PostgreSQL, plus other sources through pluginsMany databases and remote schemas
Schema comes fromDatabase catalogs; optional YAML for API-level changesDatabase catalogs; smart comments and pluginsTracked tables and relationships in metadata
Extension modelConfiguration only; behavior in SQL, event triggers and federation (why)Plugin and inflection systemMetadata, actions, remote schemas

Features ​

FeaturegraphpgPostGraphile v5Hasura
Queries compile to one SQL statementYes (lateral joins, JSON aggregation)Plan-based; batches stepsYes
Relay connections, keyset pagination, node(id:)YesYesRelay support varies by version
Filtering, ordering, aggregatesBuilt in, including relation filters (some / every / none)Built in or through pluginsBuilt in
Mutations: CRUD, upsertYesYesYes
Nested writes (create / connect / update / sync)YesPluginYes (nested inserts)
Functions as fields (queries and mutations)Yes, by volatilityYesYes
Views, materialized views, declared keysYes (keys and relations in YAML)Yes (smart comments)Yes
Composite types, enums, ranges, domainsYesMostlyPartial
Row-Level SecurityJWT claims set as session settings; policies in PostgreSQLThrough pgSettings; policies in PostgreSQLRole permissions in metadata
Schema pruned by database privilegesYes (GRANTs)YesPer role from metadata
AuthenticationOAuth 2.0 resource server (JWKS, discovery)Bring your own (middleware)JWT or webhook
Apollo Federation subgraphYes (v2)PluginYes (v3)
@defer / @streamYesYesNo
SubscriptionsLive queries, opt-in: any query field, re-run every interval and sent when it changes, under RLS; WebSocket, SSE, multipart (guide)Live queries and subscriptions through pluginsYes (live queries and streaming)
PostgreSQL 17/18 features (JSON_TABLE views, temporal keys, RETURNING OLD, MERGE)YesManual (custom SQL)Manual
Hierarchies (recursive, ltree)YesCustom SQL or pluginCustom SQL
Search (trigram, pgvector, full-text, PostGIS, BM25)Root fields from the indexes presentPluginsPartial
Bulk export (NDJSON / CSV) and COPY importYesNoNo
Read replicasYes (WAL-position aware)Configure sourcesYes
Depth, cost and page-size limitsYes (limits:, enforced on the plan)Plugins / customYes (depth, node limit)
Rate limitsYes (per token subject or IP, RateLimit-* headers; per instance)No (use a gateway)Yes (Cloud / Enterprise)
Partial results (one root field failing alone)Yes (root fields run apart only after a failure; onError)Yes (per field)No (one statement fails the request)
Persisted queries and allow-listsYes (APQ, Apollo and Code Generator manifests, allow-list)Yes (@grafserv/persisted)Yes (allow-lists)
Multi-database joinsThrough PostgreSQL foreign data wrappers, in one statement (guide)Yes, across sourcesYes
Event triggers / webhooksYes: captured in the change's transaction, Standard Webhooks signatures, retries and dead letter, cron schedules (events)No (use a job queue)Yes
REST endpointsYes (see below)NoYes (REST endpoints from saved queries)
Admin console / schema editorGraphiQL and Scalar onlyRuru IDEFull web console
Live schema reload on DDL changesYes (--mode dev)Watch modeConsole and metadata API
Telemetrytracing spans over OTLP, sqlcommenterPluginsBuilt in

When something else fits better ​

  • You need a graphical point-and-click admin console to modify schemas or click-to-track relationships: Hasura Console or Supabase Studio. graphpg is strictly code-first and declarative: schema evolution happens via database migrations, and behavior is configured in version-controlled graphpg.yaml (with embedded GraphiQL and Scalar reference UIs for interactive exploration).
  • You want to rewrite schema generation using in-process custom plugins (running arbitrary JavaScript or Python inside the engine's query resolver pipeline): PostGraphile. graphpg purposefully enforces strict single-statement safety and transaction boundaries by keeping logic in PostgreSQL functions, constraints, RLS policies, or composing at the gateway with Apollo Federation.
  • You need cursor-based streaming subscriptions for firehose event logs rather than live queries: Hasura streaming. graphpg focuses on live query subscriptions (debounced re-evaluation delivering the latest result over WebSocket, SSE, or multipart) and transaction outbox event triggers (guaranteed delivery of row-change webhooks).

Compared with PostgREST ​

PostgREST is the established way to serve a PostgreSQL schema as a REST API: a single Haskell binary, in production since 2014, whose design principle is that "the structural constraints and permissions in the database determine the API endpoints and operations". graphpg shares that principle and the same architecture (one statement and one transaction per request, authorization in PostgreSQL), but it is a GraphQL server first whose REST API is planned onto the same engine, so the two APIs share filters, pagination, nested writes, limits and error codes. PostgREST has no GraphQL API (the separate pg_graphql extension is another project), and it has had a decade to grow the HTTP surface graphpg's REST API does not yet have.

At a glance ​

graphpg RESTPostgREST
RuntimeRust, single binary (and a GraphQL API on the same engine)Haskell, single binary; REST only
Maturity1.0, young; behaviour covered by an integration suite against PostgreSQL 18Mature, widely deployed, large ecosystem and client libraries
API shapeResources /rest/<table>, /rest/rpc/<function>; typed query parametersResources /<table>, /rpc/<function>; operator grammar in the values (?age=gt.18)
DescriptionOpenAPI 3.1, typed parameters and nested-write bodies, one per container, pinnableOpenAPI 2.0 (Swagger), depends on the requesting role; 3.x rewrite not shipped at the time of writing
AuthorizationOAuth 2.0 resource server; claims reach RLS policies; the role a request runs as is fixed by configurationJWT; the role claim selects the PostgreSQL role (SET ROLE); claims reach policies; pre-request function
PaginationKeyset cursors (first / after), offset, Link headerslimit / offset, Range headers, Content-Range; no keyset
Configurationgraphpg.yaml with dev: / prod: blocks; secrets in the environmentFile, environment (PGRST_*) or in-database settings
Typical fitYou want GraphQL and REST from one service, nested writes, typed limits and a typed specYou want the most complete, battle-tested REST surface and its HTTP features

Reading ​

Featuregraphpg RESTPostgREST
Filter operatorseq, neq, gt, gte, lt, lte, in, nin, like, ilike, startsWith, endsWith, regex, iregex, isNull, isDistinctFrom, …Any / …All of a list, array (contains, containedBy, overlaps), range (contains, strictlyLeftOf, strictlyRightOf, adjacentTo, overlaps, containedBy), JSON key and path operators, and the rest of the GraphQL filter inputs (ltree, PostGIS) through a JSON filtereq, gt, gte, lt, lte, neq, like, ilike, match, imatch, in, is, isdistinct, cs, cd, ov, sl, sr, nxr, nxl, adj, fts, plfts, phfts, wfts; any / all modifiers
POSIX regular expressionsYes (regex, iregex; match and imatch too)Yes (match, imatch)
and / or / notIn the query string (filter[or][0][a][eq]=1&filter[or][1][b][gt]=2, filter[not]…, nested), and in the JSON filter (?filter={…}, POST …/search)In the query string (or=(a.eq.1,b.gt.2), not.)
Filtering through relationsfilter[author][name][eq]=…, some / every / none / count on to-manyEmbedded filters with !inner; is.null anti-joins
Complex filters in a bodyPOST /rest/<table>/search with the same JSON filter GraphQL takesNo (the query string only; or an RPC function)
Orderingorder=name,-created_at; also by related columnsorder=name.desc.nullslast; also by embedded to-one columns
NULLS FIRST / LASTYes: order=-rank:nullslast (GraphQL RANK_DESC_NULLS_LAST), keyset pagination includedYes
Column selectionfields=a,bselect=a,b, with renaming and casts (total:amount::text)
Embedding relationsinclude=posts,posts.author (to-one, to-many, many-to-many); all columns of the embedded rowsselect=*,posts(*), nested, with column lists, hints (!fk), !inner joins
Filter, order and limit inside an embeddingYes, to-many relations (filter[posts][status][eq]=x, order[posts]=-createdAt, first[posts]=5, offset[posts]=10); sparse fieldsets per relation (fields[posts]=id,title); inner=posts / anti=posts for inner joins and anti-joinsYes (posts.order=…, posts.limit=…, posts.status=eq.x, !inner)
Spread (...author(name))NoYes
Computed relationshipsFunctions mounted on tables that need no argument: rows through include, values through fields; with the embedding controlsYes (set-returning functions)
Paginationfirst / after / last / before (keyset, stable under writes), offset, Range: items=A-B (206, Content-Range, 416); Link headerslimit / offset, Range header; keyset not supported
Total countPrefer: count=exact, planned, estimated (X-Total-Count, X-Total-Count-Estimate, and total in Content-Range)Prefer: count=exact, planned, estimated (Content-Range)
Server-side maximumlimits.page_size.max (rejects), default page sizedb-max-rows (silently truncates)
Aggregatesmin, max, sum, avg, distinctCount, stddev*, variance*, median, percentile; group by columns, truncated dates and bins; having; time zones; gap fillingavg, count, max, min, sum; grouping by the other selected columns; disabled by default; no HAVING; no ordering by an aggregate
JSON column accessThe whole value; JSON-key filters; a path argument in GraphQL-> / ->> in select, filters and order
Full-text searchPOST …/search/full-text (ranked, tsvector) and the filter inputsfts / plfts / phfts / wfts operators
Other searchFuzzy (pg_trgm), semantic (pgvector), BM25, hybrid, nearest (PostGIS), and GET /rest/search across tables; scored, best firstNo (build with functions)
Views and materialized viewsYes (writable where PostgreSQL can update them; keys declared in YAML)Yes
Spatial dataGeoJSON values, filters, nearest searchThrough PostGIS and GeoJSON media-type handlers

Writing ​

Featuregraphpg RESTPostgREST
Create one rowPOST JSONPOST JSON
Bulk createPOST JSON array or CSV, one statement, bounded by limits.bulk_rows; missing=default|nullPOST JSON array or CSV; ?columns= to pick keys; form-encoded bodies
Nested writes (create / connect / update / sync children in one request)Yes: the GraphQL input shape, validated to any depth, atomicNo (multi-table writes need an RPC function)
UpdatePATCH /rest/<table>/<key>, or PATCH /rest/<table>?<filters> (many rows); JSON, merge-patch+json or, for a row, json-patch+jsonPATCH /<table>?<filters> (any filter; many rows)
DeleteDELETE /rest/<table>/<key> or DELETE /rest/<table>?<filters>DELETE /<table>?<filters> (any filter; many rows)
Update or delete many rows by filterYes, with the list's filter; refused without one (unfiltered=true to mean it); at most limits.bulk_rows rows, else rolled back (413); X-Affected-CountYes; Prefer: max-affected=N guards the count
UpsertPUT /rest/<table>/<key> (primary key); PUT /rest/<table> with an array, conflict=<unique columns>, resolution=merge|ignore, update=<columns> (query parameters, no vendor header)POST with resolution=merge-duplicates / ignore-duplicates and on_conflict=<unique columns>; PUT by primary key
Missing columnsmissing=default|null (query parameter, bulk writes)Prefer: missing=default chooses the default over null
Response shapePrefer: return=minimal (204) or the row (default); Location and ETagreturn=minimal (default), headers-only, representation; Preference-Applied
Row as it was before the writePrefer: previous (RETURNING OLD, PostgreSQL 18)No
Optimistic concurrencyRow version as ETag / If-Match (412 STALE_VERSION); conditional GET / HEAD with If-None-Match (304 Not Modified)No row versions
TransactionsOne per request; automatic retry on serialization failures and deadlocksOne per request; Prefer: tx=rollback (with db-tx-end) for dry runs
Unknown input members400: validated against the GraphQL input types, at any depthUnknown columns are an error from PostgreSQL

Throughput against PostgREST ​

benchmarks/rest runs the same requests against both on one PostgreSQL (18.6), with the same schema, data (100 000 posts, 300 000 comments) and a pool of 10 connections each: graphpg 1.0.1 and PostgREST 16.4, 20 virtual users, 10 s per scenario, one run on one machine (AMD Ryzen 9 9900X) that also runs the databases and the load generator.

Scenariographpg req/sPostgREST req/sgraphpg p50 / p95 msPostgREST p50 / p95 ms
List of 20 rows, 3 columns, ordered by key33426212820.49 / 0.800.71 / 1.86
List filtered on an enum, newest first (indexed)32501198940.52 / 0.800.74 / 2.21
One row by primary key74031246060.22 / 0.350.60 / 2.05
20 posts with author and comments embedded17227119950.97 / 1.671.22 / 3.51
Pagination: offset deep into 100 000 rows310028846.34 / 9.096.36 / 12.65
Pagination: rows after a key, deep into 100 000 rows35040220770.48 / 0.740.70 / 1.82
Insert one row532849483.49 / 4.303.70 / 5.85
Update one row by key540039703.49 / 4.253.93 / 11.14
Update 10 rows by filter419142924.11 / 7.594.10 / 7.80

What it shows: both are limited by PostgreSQL where a query is expensive (offset deep into a table takes about 6 ms in both, and is the slowest read: keyset pagination is what makes it fast in both), and the difference is in the cost around the query, where graphpg's lower latency and higher throughput on reads come from. What it does not: it is a small indexed schema on loopback, with the load generator on the same machine, and the two connect as the same database user (PostgREST then switches role per request, which is how it enforces roles). Treat the ratios as indicative, rerun it on your hardware and on your own queries (how), and read the feature comparison as the more important part of the choice.

Functions ​

Featuregraphpg RESTPostgREST
Endpoint/rest/rpc/<function>/rpc/<function>
GET vs POSTGET and POST for STABLE / IMMUTABLE; POST only for VOLATILE (405 otherwise)Same rule
ArgumentsQuery parameters (GET) or {"args": {…}} (POST), camelCase names, typed and validatedQuery parameters or a JSON object; PostgreSQL parameter names
Set-returning functionsA list with the table's own filters, ordering, pagination and embeddingThe same
Single JSON body parameter (json / jsonb)Yes (raw JSON body or {"args": {…}})Yes
Variadic functionsNot exposedYes
OverloadsThe first one created is exposedBy argument names
ProceduresNot exposedNot supported
Functions of a row (computed columns)GraphQL fields onlySelectable like columns

Content, HTTP and routing ​

Featuregraphpg RESTPostgREST
Media typesJSON (and application/problem+json errors); CSV lists and CSV bulk creates; multipart/form-data writes; the bytes of a bytea column as their own media type; CSV and NDJSON streaming through the GraphQL /export endpointJSON, CSV, GeoJSON, plain text, XML, binary; custom handlers (domains, aggregates); application/vnd.pgrst.object for one row
HEAD and OPTIONSHEAD answers like GET without a body; OPTIONS lists the methods (Allow, Accept-Post, Accept-Patch) and CORS preflights are answered by the CORS layer; both are in the OpenAPI documentYes
Several schemas in one deploymentAll database.schemas merged into one API; REST adds a path per schema (/rest/{schema}/{resource}, /rest/{schema}/rpc/{function}) next to the default schema's shortcut, with no headersOne schema per request, chosen by Accept-Profile / Content-Profile
CORSConfigurable (server.cors): any origin in dev, none in prod unless listedConfigurable allowed origins
Request ID / Server-Timing / EXPLAIN by headerX-Request-Id echo/generation, dev Server-Timing, tracing spans, GraphQL debug.explain in devX-Request-Id echo, Server-Timing, Accept: application/vnd.pgrst.plan

Authentication and authorization ​

FeaturegraphpgPostgREST
TokenOAuth 2.0 access token (RFC 9068), Authorization: BearerJWT, Authorization: Bearer
Key materialIssuer's JWKS found by OpenID Connect discovery or RFC 8414, cached and refreshed on key rotationA static secret or a literal JWK / JWKS in the configuration
AlgorithmsRS*, PS*, ES256/384, EdDSA (configurable)HS256 and RSA family
Which PostgreSQL role a request runs asFixed by configuration (authenticated / anonymous); tokens never choose itThe token's role claim (SET ROLE); anonymous role without a token
Claims in policiesMapped to session settings you namerequest.jwt.claims (and headers, cookies) settings
Custom per-request checkRow-Level Security policiesdb-pre-request function, and policies
Token validation cacheVerified keys cachedValidation results cached (SIEVE)
Row-Level SecurityYesYes
Schema / spec pruned by privilegesYes, with rbac.enabled (the connecting role's GRANTs; one per container)Yes (per request role)

API description ​

FeaturegraphpgPostgREST
FormatOpenAPI 3.1 with JSON Schema 2020-12, JSON or YAMLOpenAPI 2.0 (Swagger)
Typed query parametersYes: filter (deepObject), order, fields, include and group are enumerated from the schemaOperators are part of the value, so the grammar is not described as typed parameters
Write bodies, nested writesDescribed from the GraphQL input types (Input.<Type>)Row schemas only
Role-specificOne document per container (run another container for another role)One per request role (openapi-mode)
Pinning and CIgraphpg schema export --check; startup refuses drift in prod; snapshot-testedNo
Interactive referenceScalar, bundled in the binary (offline)Bring your own (Swagger UI)
CustomizationSQL comments and graphpg.yaml descriptionsSQL comments; a function can replace the whole document (db-root-spec)

Errors and limits ​

FeaturegraphpgPostgREST
Error formatRFC 9457 application/problem+json with a stable codeJSON code, message, details, hint
PostgreSQL's own messagesMasked in prod behind a request ID that is logged; typed constraint codes (EMAIL_TAKEN, AUTHOR_NOT_FOUND) insteadForwarded as they are
Custom HTTP status from SQLRAISE … SQLSTATE 'PT402' (the same convention), and errors.map from SQLSTATEs or constraint namesRAISE … SQLSTATE 'PT402'
Page sizeDefault and maximum (limits.page_size)db-max-rows
Depth and costlimits.depth, limits.cost, enforced on the plan before SQL is compiledNot built in (the docs suggest pg_plan_filter)
Bulk, body size, groupslimits.bulk_rows, limits.request_body_bytes, limits.groupsNot built in
statement_timeoutlimits.statement_timeout (default 30s) on every connectionSet it on the role or database
AggregatesOn, bounded by the limits aboveOff unless db-aggregates-enabled

Operations ​

FeaturegraphpgPostgREST
Schema reloadNOTIFY graphpg_reload or SIGHUP, no restart, refused when it breaks pinned schemas; dev also reloads on DDL (event trigger)NOTIFY pgrst, 'reload schema' or a signal, no restart
Configuration reloadRestartNOTIFY pgrst, 'reload config', in-database settings, per-role settings
Health and metrics/health, /live, /ready, Prometheus /metrics (optionally on a port of its own) and OTLP metrics, named after the OpenTelemetry conventions/live, /ready and Prometheus /metrics on an admin port
Tracing and logsOpenTelemetry traces with W3C traceparent propagation, OTLP logs, JSON logs with trace ids, sqlcommenter tagsLogs, log-query, Server-Timing, trace-header echo
Read replicasBuilt in (WAL-position aware routing)Not built in (use a load balancer or pooler)
Rate limitsBuilt in (limits.rate_limit, per token subject or IP, RateLimit-* headers)Not built in (use a gateway such as Nginx)
Connection poolingBuilt-in pool; replicas separatelyBuilt-in pool (db-pool, acquisition timeout, lifetime)
Graceful shutdownYes (SIGTERM)Yes
PackagingStatic binary, signed distroless image (about 27 MB)Static binaries and Docker image

Moving from PostgREST ​

The grammar differs but maps one to one for the common cases:

PostgRESTgraphpg REST
GET /people?age=gte.18&name=ilike.*ann*GET /rest/people?filter[age][gte]=18&filter[name][ilike]=%25ann%25
GET /people?select=id,nameGET /rest/people?fields=id,name
GET /people?order=age.desc,nameGET /rest/people?order=-age,name
GET /people?select=*,posts(*)GET /rest/people?include=posts
GET /people?or=(age.lt.18,age.gt.65)POST /rest/people/search with {"filter": {"or": [{"age": {"lt": 18}}, {"age": {"gt": 65}}]}}
GET /people?limit=20&offset=40GET /rest/people?first=20&offset=40, or follow the Link: rel="next" header (keyset)
Range: 0-19 / Content-RangeRange: items=0-19 → 206 with Content-Range: items 0-19/* (the total with Prefer: count=exact)
Prefer: count=exactPrefer: count=exact → X-Total-Count
POST /people + Prefer: return=representationPOST /rest/people (the representation is the default; return=minimal for none)
POST + resolution=merge-duplicatesPUT /rest/people/<key>
PATCH /people?id=eq.1PATCH /rest/people/1
DELETE /people?id=eq.1DELETE /rest/people/1
GET /rpc/fn?a=1GET /rest/rpc/fn?a=1 (names in camelCase)
POST /rpc/fn with {"a": 1}POST /rest/rpc/fn with {"args": {"a": 1}}
Accept-Profile: otherNot supported: schemas are merged into one API
GET / (OpenAPI 2.0)GET /openapi.json (OpenAPI 3.1; openapi.spec)

Rows differ in three ways worth knowing: field names are camelCase (createdAt), enums are the GraphQL values (PAID), and 64-bit integers and numeric are strings (so no precision is lost). The REST API guide has the details.

When PostgREST fits better ​

  • You need GeoJSON, XML, plain-text or custom media types (domains, aggregates) from the REST API itself.
  • You rely on spread embeds (...author(name)), or JSON arrow operators in the URL (->, ->>; graphpg has path[column]).
  • You want the token to choose the PostgreSQL role, or Accept-Profile to switch schemas per request.
  • You need configuration reload without a restart (graphpg reloads the schema, not its configuration).
  • You value a decade of production use, a large ecosystem and client libraries (postgrest-js, ...).

When graphpg fits better ​

  • You want GraphQL and REST from one service, with the same filters, pagination, errors and limits in both.
  • You write related rows together: nested writes, connectOrCreate, MERGE-based child syncing, optimistic If-Match, the row as it was (Prefer: previous).
  • You paginate with stable keyset cursors, or need richer aggregates (HAVING, percentiles, time-bucketed groups).
  • You want a typed OpenAPI 3.1 document that is pinned in CI and describes parameters and nested bodies, and an offline reference UI.
  • You want safety limits (depth, cost, bulk, timeouts) and masked errors built in.
  • You want search over trigram, vector, BM25 and full-text indexes without writing functions.
  • You run it as an OAuth 2.0 resource server with JWKS discovery, and want tokens to never pick a database role.

Known gaps ​

What graphpg's REST API does not do today, from the tables above, in the order we would expect people to hit them: spread embeds, GeoJSON, XML and custom media types, and Accept-Profile-style schema selection (graphpg serves each schema at /rest/{schema}/… instead).

Where graphpg is a fit ​

  • You want a small, self-contained service that you configure rather than program, deployed as a binary or container.
  • Your authorization already lives in PostgreSQL (roles, grants, Row-Level Security) and you want the APIs to follow it.
  • You use recent PostgreSQL features (temporal keys, JSON_TABLE, composite and range types) and want them in the schema without hand-written SQL.
  • You run it as a subgraph behind a federation router.

Performance ​

Throughput depends on your schema, indexes and hardware far more than on the server. graphpg compiles each request to one statement, as PostgREST does, so the difference is usually small next to the database's own work. The one published comparison is against PostgREST, with its caveats. For benchmarking graphpg on your own schema, see ../benchmarks/, and run the other tool against the same database, queries and hardware.

Sources ​