Types from the database to the UI
The GraphQL SDL and the OpenAPI 3.1 document are generated from the schema, rewritten on every migration and pinned in CI. Generated TypeScript catches a renamed column before anything runs.
End-to-end types
Write the schema in SQL. One Rust binary serves both APIs and keeps the GraphQL SDL and OpenAPI 3.1 files current as migrations land. Rename a column and the type checker lists every place that broke, for you or your coding agent.
The GraphQL SDL and the OpenAPI 3.1 document are generated from the schema, rewritten on every migration and pinned in CI. Generated TypeScript catches a renamed column before anything runs.
End-to-end types
A vector index and a lexical one give semantic, BM25 and hybrid search fields, fused by reciprocal rank in one SQL statement, scoped by filters and Row-Level Security.
Search for RAG
Put Apollo's MCP Server in front of graphpg and each GraphQL operation you list becomes a typed tool, run with the agent's own token.
Set up MCP
Each agent gets a role, its rows and nothing else. Page size, depth, cost and timeouts are checked before any SQL runs; an allow-list can limit it to known operations.
Guardrails
Nested selections compile to lateral joins and JSON aggregation, so a deep query is one round trip and never N+1. Connections page by keyset, an index seek on every page.
GraphQL API
Signed webhooks on row changes and schedules, captured in the change's own transaction, start agent work without polling. Live queries push new results.
Event triggers
End-to-end types
How often have you asked an agent to "change this everywhere" and found, a day later, the one place it missed? In a large codebase, search finds most uses of a column, not all of them. When the types come from the database, the missed places stop compiling, and the agent reads the errors and fixes them in the same session.
0014_rename.sqlpg_catalogschema.graphql · openapi.json0.6–1.0 ssrc/gql · api.d.ts5–6 stsc0.3 sexport const patients = pgTable("patients", {
id: uuid("id").primaryKey(),
family: text("family"),
given: text("given"),
});graphql(`query PatientList {
patientsConnection(orderBy: [FAMILY_ASC]) {
nodes { id family given }
} }`);
api.GET("/rest/patients", { params: {
query: { fields: ["id", "family"] } } });✓ No problems. Every field, filter and column name is checked against the database.
Measured on a 177-table FHIR R5 schema with graphpg 1.1.0. How it was measured →
Schema in SQL
A table states its columns, keys, relations and rules in a few lines. Constraints, policies and grants say what is allowed, not how to check it, and PostgreSQL enforces them on every write, from any client.
SQL has decades of public examples and documentation, and models write it fluently. The API that follows from it needs no resolver code, so most of an agent's backend work is one migration.
Every change is a migration a person can review: a SQL file, or a Drizzle schema diff that generates one. The database rejects what breaks its own rules, such as dropping a column a function still reads.
The same schema becomes the GraphQL SDL and the OpenAPI 3.1 document, so typed GraphQL clients and typed REST clients both follow it. Neither is maintained by hand.
A violated constraint comes back as a code derived from it, such as EMAIL_TAKEN or AUTHOR_NOT_FOUND, listed per type in both documents, so generated code can handle each one.
graphpg schema export --check fails when the committed files are out of date, and production refuses to start on a schema it was not built for.
Built for AI
Assistants and autonomous agents read and write your data through tools: search for retrieval, lookups and lists for context, mutations for actions.
/restCoding agents write the schema in SQL and the app against generated, typed APIs, with checks that catch their mistakes early.
@constraint on fieldsRetrieval
Create a vector index and a lexical one; graphpg adds semantic, lexical and hybrid search to both APIs. Hybrid search fuses the two rankings by reciprocal rank inside one SQL statement, after your filters and policies have scoped the candidates.
CREATE TABLE documents (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id uuid NOT NULL,
title text NOT NULL,
body text NOT NULL,
embedding vector(1536) NOT NULL
);
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops); -- semantic
CREATE INDEX ON documents USING gist (title gist_trgm_ops); -- lexical, or ParadeDB bm25query Retrieve($embedding: [Float!]!, $tenant: UUID!) {
documentsHybridSearch(query: "refund policy", vector: $embedding, first: 5,
filter: { tenantId: { equalTo: $tenant } }) {
hits { score node { id title body } }
}
}curl -X POST localhost:4000/rest/documents/search/hybrid \
-H 'content-type: application/json' \
-d '{"query": "refund policy", "vector": [0.012, -0.034, ...], "first": 5}'
MCP
Apollo MCP Server turns GraphQL operations into MCP tools. Point it at graphpg, list the operations an agent may run, and give it the agent's token: each call runs as that role, under its policies and limits.
endpoint: http://localhost:4000/graphql
transport:
type: stdio
headers:
Authorization: "Bearer ${env.GRAPHPG_AGENT_TOKEN}"
schema:
source: local
path: ./schema.graphql # graphpg export-graphql
operations:
source: local
paths:
- ./operations/SearchPosts.graphql
- ./operations/AddComment.graphql
overrides:
mutation_mode: explicit # listed mutations onlyquery SearchPosts($query: String!) {
postsFuzzySearch(query: $query, first: 5) {
hits { score node { id title excerpt(length: 200) } }
}
}{
"mcpServers": {
"graphpg": {
"command": "apollo-mcp-server",
"args": ["/path/to/mcp.yaml"],
"env": { "GRAPHPG_AGENT_TOKEN": "..." }
}
}
}One schema, two APIs
Schema design stays in PostgreSQL. This is a slice of the blog example; everything below is served from it with no further setup.
CREATE TYPE post_status AS ENUM ('draft', 'published', 'archived');
CREATE TABLE posts (
id uuid PRIMARY KEY DEFAULT uuidv7(),
author_id uuid NOT NULL REFERENCES authors (id),
title text NOT NULL,
body text NOT NULL,
status post_status NOT NULL DEFAULT 'draft',
word_count int GENERATED ALWAYS AS (cardinality(string_to_array(body, ' '))) STORED,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX posts_title_trgm_idx ON posts USING gist (title gist_trgm_ops);
-- a function of a row becomes a computed field: Post.excerpt
CREATE FUNCTION posts_excerpt(p posts, length int DEFAULT 80) RETURNS text
LANGUAGE sql STABLE AS $$ SELECT left(p.body, length) $$;{
postsConnection(
first: 10
filter: { status: { equalTo: PUBLISHED } }
orderBy: [CREATED_AT_DESC]
) {
nodes {
title
wordCount # generated column, read-only
excerpt(length: 30) # posts_excerpt(p posts, length int)
author { displayName }
tags { name } # many-to-many through post_tags
}
pageInfo { hasNextPage endCursor }
}
postsFuzzySearch(query: "nanosec") { hits { score node { title } } }
}mutation {
createPost(input: {
authorId: "0192f1a0-0000-7000-8000-000000000001"
title: "Hello"
body: "First words."
tags: { connect: [{ name: "computing" }] } # nested write, one transaction
}) {
post { id title wordCount tags { name } }
}
}
# an unknown authorId fails with AUTHOR_NOT_FOUND on input.authorId,
# and the whole operation rolls back# filter, order, keyset pagination (Link: rel="next") and embedding
curl -g 'localhost:4000/rest/posts?filter[status][eq]=PUBLISHED&order=-createdAt&first=5&include=tags'
# a nested write: the author and a post in one transaction
curl -X POST localhost:4000/rest/authors -H 'content-type: application/json' \
-d '{"username": "ada2", "displayName": "Ada", "posts": {"create": [{"title": "Hello", "body": "Hi"}]}}'
# grouped aggregates, fuzzy search, and a function
curl -g 'localhost:4000/rest/posts/aggregate?group=status&max=createdAt'
curl -X POST localhost:4000/rest/posts/search/fuzzy -d '{"query": "nanosec"}' -H 'content-type: application/json'
curl 'localhost:4000/rest/rpc/popular_tags'What it generates
From tables, views, foreign keys (composite too), unique keys and many-to-many junctions. Relay connections, node(id:) and global IDs.
Filters across relations (some / every / none), grouped aggregates, ordering by related columns and NULL placement.
Create, update, delete, upsert, bulk, and nested writes across relations in one transaction, with typed constraint errors.
STABLE functions become queries, VOLATILE ones mutations, and functions of a row become computed fields.
Enums, composites, domains, arrays, ranges and multiranges, ltree, jsonb, pgvector and PostGIS.
Trigram, full-text, pgvector, BM25 and hybrid search fields, plus PGroonga, pgvectorscale and VectorChord.
Temporal keys (WITHOUT OVERLAPS, PERIOD), RETURNING OLD, JSON_TABLE views, MERGE-based syncing of children.
Apollo Federation v2 subgraphs, @defer / @stream, NDJSON and CSV export, COPY import, read replicas.
Foreign tables through postgres_fdw, file_fdw and Parquet join local tables in the same statement.
Prometheus metrics, OpenTelemetry traces, metrics and logs over OTLP, and JSON logs, ready for Grafana LGTM.
graphpg schema export --check fails CI when the SDL or OpenAPI document drifts; production refuses to start on drift.
graphpg advise --statements finds the costliest operations in pg_stat_statements and the indexes they lack, checked with hypopg.
Performance
Each request is one statement, so most of the time is PostgreSQL's own work, and graphpg keeps the rest small. Against PostgREST on the same database, the reads below ran at 1.4 to 3 times the throughput. Where PostgreSQL dominates it was about even: deep offset pagination, and bulk updates by filter (2% behind). Single-row writes ran at 1.1 to 1.4 times.

benchmarks/rest on one machine (2026-10-07). Reads only; every scenario, writes included, is in the comparison.Compared
graphpg covers the ground of PostGraphile and Hasura on the GraphQL side and PostgREST on the REST side, from one binary. The comparison lists what each tool does, including where the others are broader.
| graphpg | PostGraphile v5 | Hasura | PostgREST | |
|---|---|---|---|---|
| Runtime | Rust, single binary | Node.js | Haskell (v2), Rust (v3) | Haskell, single binary |
| GraphQL | Yes | Yes | Yes | No |
| REST | Yes, described by OpenAPI 3.1 | No | From saved queries | Yes, described by OpenAPI 2.0 |
| Schema from | Catalogs, optional YAML | Catalogs, smart comments, plugins | Metadata | Catalogs |
| Authorization | RLS with token claims; schema pruned by GRANT | RLS through pgSettings | Role permissions in metadata | RLS with JWT claims |
From the blog
Why SQL, a generated contract and a type checker let an agent change something everywhere without missing a place.
Read the article →mihai.ltdWhat PostGraphile, Hasura and PostgREST taught me, what graphpg does differently, and why GraphQL and REST should share one engine.
Read the article →mihai.ltdWhy AI coding agents routinely introduce data leaks in application-layer authorization, and how PostgreSQL RLS turns authorization into an auditable data-layer barrier.
Read the article →Written by Mihai Farcas, who builds graphpg. Follow along on GitHub.
Latest release
docker run --rm -p 4000:4000 \
-e DATABASE_URL=postgres://user:pass@host.docker.internal/mydb \
ghcr.io/mihailtd/graphpg:latest --mode devThen open localhost:4000/graphiql for GraphQL or localhost:4000/scalar for the REST reference. The image is a static binary on distroless/static, about 27 MB, non-root and signed with Sigstore.