Skip to content

graphpgType-safe GraphQL and REST, generated from PostgreSQL

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.

graphpg

End-to-end types

Change the database, and the type checker finds every place that broke ​

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.

  1. 1MigrationSQL or Drizzle0014_rename.sql
  2. 2PostgreSQLthe catalog is the sourcepg_catalog
  3. 3graphpgdev mode rewrites the contractschema.graphql · openapi.json0.6–1.0 s
  4. 4CodegenGraphQL Code Generator, openapi-typescriptsrc/gql · api.d.ts5–6 s
  5. 5Type checkereditor and CI see every breaktsc0.3 s
Before: the app compiles
src/schema.ts Drizzle
export const patients = pgTable("patients", {
  id: uuid("id").primaryKey(),
  family: text("family"),
  given: text("given"),
});
src/patients.ts GraphQL + REST
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

SQL is the schema language agents already write well ​

Declarative and compact

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.

Familiar to models

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.

Auditable

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.

One contract, two APIs

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.

Typed errors

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.

Drift fails CI

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

Two ways AI uses graphpg ​

Agents that use your data

Assistants and autonomous agents read and write your data through tools: search for retrieval, lookups and lists for context, mutations for actions.

  • Vector, BM25 and hybrid search from the indexes you already have
  • MCP tools through Apollo's MCP Server, or OpenAPI tools over /rest
  • The agent's own role, Row-Level Security, limits and an operation allow-list
  • Webhooks on row changes to start agent runs
Agents that use your data →

Agents that build applications

Coding agents write the schema in SQL and the app against generated, typed APIs, with checks that catch their mistakes early.

  • GraphQL SDL and OpenAPI 3.1 in files, for typed client generation
  • Drift between database and contract fails CI and production startup
  • Typed error codes from constraints, @constraint on fields
  • No resolvers: behaviour lives in SQL, where it can be tested
Agents that build applications →

Retrieval

Hybrid search is one field ​

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.

sql
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 bm25
graphql
query Retrieve($embedding: [Float!]!, $tenant: UUID!) {
  documentsHybridSearch(query: "refund policy", vector: $embedding, first: 5,
                        filter: { tenantId: { equalTo: $tenant } }) {
    hits { score node { id title body } }
  }
}
bash
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}'
Hybrid search: a lexical and a semantic ranking computed in the database, fused by reciprocal rank and returned as one result

MCP

Tools for your agents, from operations you choose ​

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.

yaml
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 only
graphql
query SearchPosts($query: String!) {
  postsFuzzySearch(query: $query, first: 5) {
    hits { score node { id title excerpt(length: 200) } }
  }
}
json
{
  "mcpServers": {
    "graphpg": {
      "command": "apollo-mcp-server",
      "args": ["/path/to/mcp.yaml"],
      "env": { "GRAPHPG_AGENT_TOKEN": "..." }
    }
  }
}

One schema, two APIs

Write SQL. Get GraphQL and REST. ​

Schema design stays in PostgreSQL. This is a slice of the blog example; everything below is served from it with no further setup.

sql
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) $$;
graphql
{
  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 } } }
}
graphql
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
bash
# 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

Everything you would otherwise write by hand ​

Types, relations and lookups

From tables, views, foreign keys (composite too), unique keys and many-to-many junctions. Relay connections, node(id:) and global IDs.

Filtering, ordering, aggregates

Filters across relations (some / every / none), grouped aggregates, ordering by related columns and NULL placement.

Mutations

Create, update, delete, upsert, bulk, and nested writes across relations in one transaction, with typed constraint errors.

Functions as fields

STABLE functions become queries, VOLATILE ones mutations, and functions of a row become computed fields.

The whole type system

Enums, composites, domains, arrays, ranges and multiranges, ltree, jsonb, pgvector and PostGIS.

Search from your indexes

Trigram, full-text, pgvector, BM25 and hybrid search fields, plus PGroonga, pgvectorscale and VectorChord.

Modern PostgreSQL

Temporal keys (WITHOUT OVERLAPS, PERIOD), RETURNING OLD, JSON_TABLE views, MERGE-based syncing of children.

Federation and streaming

Apollo Federation v2 subgraphs, @defer / @stream, NDJSON and CSV export, COPY import, read replicas.

Other databases and files

Foreign tables through postgres_fdw, file_fdw and Parquet join local tables in the same statement.

Observability

Prometheus metrics, OpenTelemetry traces, metrics and logs over OTLP, and JSON logs, ready for Grafana LGTM.

Pinned schemas

graphpg schema export --check fails CI when the SDL or OpenAPI document drifts; production refuses to start on drift.

Index advice

graphpg advise --statements finds the costliest operations in pg_stat_statements and the indexes they lack, checked with hypopg.

Performance

Fast where it counts ​

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.

Requests per second, graphpg against PostgREST 16.4 on PostgreSQL 18.6: single row by key 74,031 against 24,606; keyset pagination 35,040 against 22,077; list of 20 rows 33,426 against 21,282; filtered list 32,501 against 19,894; 20 posts with author and comments 17,227 against 11,995
graphpg 1.0.1 and PostgREST 16.4, one run of benchmarks/rest on one machine (2026-10-07). Reads only; every scenario, writes included, is in the comparison.

Compared

Where it sits ​

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.

graphpgPostGraphile v5HasuraPostgREST
RuntimeRust, single binaryNode.jsHaskell (v2), Rust (v3)Haskell, single binary
GraphQLYesYesYesNo
RESTYes, described by OpenAPI 3.1NoFrom saved queriesYes, described by OpenAPI 2.0
Schema fromCatalogs, optional YAMLCatalogs, smart comments, pluginsMetadataCatalogs
AuthorizationRLS with token claims; schema pruned by GRANTRLS through pgSettingsRole permissions in metadataRLS with JWT claims

From the blog

Why it works this way ​

Written by , who builds graphpg. Follow along on GitHub.

Latest release

What's new ​

v1.2.0October 18, 2026
  • Complete support
  • Documentation website
  • Ready for a public repository

Try it in a minute ​

bash
docker run --rm -p 4000:4000 \
  -e DATABASE_URL=postgres://user:pass@host.docker.internal/mydb \
  ghcr.io/mihailtd/graphpg:latest --mode dev

Then 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.