One annotated GraphQL SDL document is the source of truth for both halves of a
PostgreSQL 19 SQL/PGQ property-graph application.
gopgql generates the DDL — vertex and edge tables plus the
CREATE PROPERTY GRAPH mapping, as goose
migrations — and compiles GraphQL queries to GRAPH_TABLE ... MATCH ... COLUMNS
statements.
Two binaries ship: gopgql, the schema CLI (generate, migrate,
conform), and gopgql-mcp, an MCP server that serves one schema and one
database to an AI agent.
Property graphs are a PostgreSQL 19 feature; nothing here runs on 18.
See SPEC.md for the design, the milestones and the SQL/PGQ
limitations gopgql works around.
gopgql.garutyunov.com runs the real
sdl + generator + migrate + compiler + shape packages as WebAssembly.
Edit an SDL document and a GraphQL query on the left and the generated DDL,
migrations and compiled SQL update on the right — traversals, migration deltas,
the depth limit, interfaces and every mapping directive, each as an editable
scenario.
Every query tab also runs what it generated. Run in PostgreSQL starts a
real PostgreSQL 19 with SQL/PGQ — a wasm build of the fork, in a Web Worker, in
memory only — applies the generated DDL and the tab's data, executes the
compiled GRAPH_TABLE query with its bind values, and hands the flat rows back
to shape, which regroups them into the nested GraphQL response the panel
leads with. That is the whole round trip: a GraphQL query in, a GraphQL response
out, with the rows one disclosure underneath as the evidence. Nothing is fetched
until the button is pressed, and nothing is sent anywhere
(SPEC.md §8.6).
Each of those tabs carries an editable Data pane, pre-filled with the
INSERTs its schema needs and applied just before the query, so you can
INSERT, UPDATE or DELETE and watch the response change. Writes there are
plain SQL rather than GraphQL mutations because a SQL/PGQ property graph is a
read-only view — nothing gopgql compiles writes through the graph, and
CompileQuery emits GRAPH_TABLE and nothing else.
gopgql does have a write path, and it is a different one: a mutation field
carrying @function compiles to a plain call to a
PL/pgSQL function the database already owns, over ordinary tables. It runs only
through a connection you hand in.
# schema.graphql
type Person @node(label: "person") {
id: ID!
name: String! @unique
email: String
follows: [Person!]! @relationship(type: "follows", direction: OUT)
followers: [Person!]! @relationship(type: "follows", direction: IN)
}gopgql generate --sdl schema.graphql --dir migrations --name people writes the
migrations that schema calls for. Tables and the property graph over them are
never mixed in one file, and the files are numbered in the order they must run:
-- migrations/0001_people_tables.sql -- +goose Up
CREATE TABLE persons (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL UNIQUE,
email text
);
CREATE TABLE follows (
source_id uuid NOT NULL REFERENCES persons (id),
target_id uuid NOT NULL REFERENCES persons (id),
PRIMARY KEY (source_id, target_id)
);
CREATE INDEX follows_target_idx ON follows (target_id);
-- migrations/0002_people_graph.sql -- +goose Up
CREATE PROPERTY GRAPH app_graph
VERTEX TABLES (
persons LABEL person PROPERTIES (id, name, email)
)
EDGE TABLES (
follows SOURCE KEY (source_id) REFERENCES persons (id)
DESTINATION KEY (target_id) REFERENCES persons (id)
LABEL follows PROPERTIES (source_id, target_id)
);Run it again after widening the SDL and gopgql folds the migrations already in the directory, diffs them against the schema, and writes only the delta — no database and no sidecar state file.
A query against that schema:
query Followed($name: String!) {
persons(name: $name) {
name
follows { name email }
}
}compiles to one statement, with variables bound as parameters rather than interpolated:
SELECT v0_k, v0_c0, v1_k, v1_c0, v1_c1
FROM GRAPH_TABLE (app_graph
MATCH (v0 IS person) -[e0 IS follows]-> (v1 IS person)
WHERE v0.name = $1 AND v0.id <> v1.id
COLUMNS (v0.id AS v0_k, v0.name AS v0_c0, v1.id AS v1_k, v1.name AS v1_c0, v1.email AS v1_c1)
)Nesting extends the same pattern instead of issuing another query, so there is
no N+1; the flat rows are regrouped into the nested GraphQL response on the Go
side. v0.id <> v1.id is there because PostgreSQL does not enforce isomorphism
— without it a self-follow would satisfy the pattern.
Prebuilt binaries for linux and macOS, amd64 and arm64, are on the
Releases page — one tar.gz
per platform carrying both binaries. They are static, so there is nothing
else to install.
From source, with a Go toolchain:
go install github.com/gaarutyunov/gopgql/cmd/gopgql@latest
go install github.com/gaarutyunov/gopgql/cmd/gopgql-mcp@latestOr as a container image, published to GHCR on every release:
docker run --rm ghcr.io/gaarutyunov/gopgql:latest gopgql versionThe image is multi-arch, carries both binaries in /usr/local/bin/, and has no
ENTRYPOINT and no CMD — the command is always given explicitly, which is
what makes it a drop-in for a compose file.
Postgres, a one-shot migration, and the MCP server:
# docker-compose.yml
name: gopgql
x-dsn: &dsn postgres://gopgql:gopgql@postgres:5432/gopgql?sslmode=disable
services:
postgres:
image: postgres:19beta2
environment:
POSTGRES_DB: gopgql
POSTGRES_USER: gopgql
POSTGRES_PASSWORD: gopgql
ports: ["127.0.0.1:55432:5432"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gopgql -d gopgql"]
interval: 2s
retries: 30
migrate:
image: ghcr.io/gaarutyunov/gopgql:latest
depends_on:
postgres: {condition: service_healthy}
environment:
GOPGQL_DSN: *dsn
volumes: ["./schema.graphql:/app/schema.graphql:ro"]
command: ["gopgql", "migrate", "--sdl", "/app/schema.graphql", "--dir", "/tmp/migrations"]
mcp:
image: ghcr.io/gaarutyunov/gopgql:latest
depends_on:
migrate: {condition: service_completed_successfully}
environment:
GOPGQL_DSN: *dsn
GOPGQL_TRANSPORT: http
GOPGQL_ADDR: ":8080"
ports: ["127.0.0.1:8765:8080"]
volumes: ["./schema.graphql:/app/schema.graphql:ro"]
command: ["gopgql-mcp", "--sdl", "/app/schema.graphql"]docker compose up -dgopgql migrate generates the migrations the SDL calls for and applies them
with goose, in one step. The --dir above is ephemeral, so the whole history is
regenerated from the SDL each run and re-applied from zero; goose skips the
versions it has already applied, so restarting the stack is a no-op.
Point psql at postgres://gopgql:gopgql@localhost:55432/gopgql to see what
the server sees.
Three complete stacks live in examples/, each with an SDL
document, a seeded corpus, a compose file and a .mcp.json:
| Example | The graph | Ports (MCP / Postgres) |
|---|---|---|
docs-graph/ |
A documentation index in PageIndex's shape — documents, a tree of sections, and citations between documents | 8765 / 55432 |
code-graph/ |
This repository's own source: packages, files, symbols, calls, imports | 8766 / 55433 |
slack-graph/ |
Team chat: channels, members, threads, messages, mentions | 8767 / 55434 |
cd examples/docs-graph
docker compose up -d --build # postgres, migrate, seed, and the server
claude # picks up ./.mcp.jsonThese build the image from the checkout rather than pulling it, so they always
run the code you have. Ask the agent to introspect first, then to query:
{ sections(title: "Root Cause") { id title cites { title } } }{"sections": [{
"id": "50000000-0000-0000-0000-000000000012",
"title": "Root Cause",
"cites": [
{"title": "Ledger Write Path"},
{"title": "Retries and Backoff"},
{"title": "Alert: Ledger Lock Wait High"}
]}]}Two of those three citations are in other documents, which a tree-shaped index
could not answer. See examples/README.md for the rest,
including the one thing worth knowing before writing a deep query: MATCH is an
inner join, so a node with no edge at the next level drops out of the result
rather than coming back with an empty list.
The examples run the server over HTTP, so it outlives its clients and several
agents can share one process — .mcp.json is just a URL. For a server an agent
spawns itself, the binary speaks stdio, which is its default:
claude mcp add gopgql --env GOPGQL_DSN="$GOPGQL_DSN" -- gopgql-mcp --sdl schema.graphqlPass the DSN through the environment rather than --dsn: an MCP configuration
file sits on disk and command-line arguments are visible to every process on the
machine. --sdl falls back to GOPGQL_SDL the same way, and a flag wins over
the environment. It is repeatable here too, and takes a directory: the server
loads whatever documents it names as one schema.
Two tools:
| Tool | What it does |
|---|---|
introspect |
Standard GraphQL introspection over the loaded schema. No arguments gives the overview; type: "Person" drills into one type; full: true returns the complete result; format: "sdl" returns the document. |
query |
Compiles a GraphQL query, executes it, and returns the nested response. variables are bound as SQL parameters, never interpolated. format: "markdown" renders a table instead of JSON. |
The server never migrates and never writes. It exposes no mutation tool and its
introspection reports a null mutationType — deliberately, and not because
mutations cannot exist: the server holds a pool it opened itself, with
default_transaction_read_only=on, and no handle from a caller. A @function
call needs a connection somebody else is responsible for, and an agent able to
enqueue work is a different authorization question from an agent able to read.
A Mutation field carrying @function maps to a PL/pgSQL function the database
already owns. gopgql derives no writes of its own — there is no generated
createPerson and no inferred input type — it calls what the SDL names.
type Mutation {
startAgentRun(
agentDigest: String! @column(name: "agent_digest")
userId: String! @column(name: "user_id")
queue: String = "agent"
priority: Int
): String! @function(schema: "dbos", name: "enqueue_workflow")
sendMessage(destination: String!): Boolean!
@function(schema: "dbos", name: "send_message", returns: VOID)
}SELECT dbos.enqueue_workflow(agent_digest => $1, user_id => $2, queue => $3)
Arguments map to parameters by name, so writing them in another order
compiles to the same call. An argument your operation does not pass is left out
of the call, so the function's own DEFAULT applies — passing it as null is a
different thing, and sends NULL. returns: VOID is declared rather than
guessed, because Boolean! cannot tell a function that returned false from
one that returned nothing.
An error the function raises arrives as an *exec.FunctionError carrying its
SQLSTATE:
var fnErr *exec.FunctionError
if errors.As(err, &fnErr) && fnErr.SQLSTATE == "P0001" { … }@readonly marks a type gopgql surfaces but does not own: it appears in the
property graph and is queryable like any other, and gopgql emits no CREATE TABLE, no ALTER, no DROP and no index for it, ever. @node(schema:) names
the PostgreSQL schema it lives in.
type Stream @node(label: "stream", table: "streams", schema: "dbos") @readonly {
id: ID!
topic: String!
seq: Int! @column(name: "offset") # quoted correctly; `offset` is reserved
}It is the per-type grain of --no-tables, and the two compose. Whether gopgql
owns a table cannot change in a delta — both directions are refused at generate
time, because neither has a migration that could be written for it.
@node(schema:) applies to any type, owned or not; gopgql emits no CREATE SCHEMA either way, so the schema has to already exist.
A table you do not own may have no id at all, and gopgql cannot add one to it.
Declare @key(fields:) and those columns become the type's identity — what the
compiler projects, groups the response by, and compares between positions:
type Step @node(label: "step", table: "operation_outputs", schema: "dbos") @readonly
@key(fields: ["workflowUuid", "functionId"]) {
workflowUuid: ID! @column(name: "workflow_uuid")
functionId: Int! @column(name: "function_id")
output: String
}For a type gopgql owns nothing changes: id stays the identity, and
@key(fields:) stays a uniqueness constraint alongside it.
An edge can be mapped onto an existing table too, which is how one table serves as both a row and the join that reaches it:
steps: [Step!]! @relationship(
type: "has_step", direction: OUT
table: "operation_outputs", schema: "dbos"
sourceKey: ["workflow_uuid"], destKey: ["workflow_uuid", "function_id"]
)Without sourceKey:/destKey:, a relationship touching a @readonly type is
refused rather than mis-generated: gopgql would otherwise have to create an edge
table referencing a table it does not own.
gopgql generate client reads the SDL and a directory of named GraphQL
operation documents, compiles every operation there and then, and writes a Go
package.
gopgql generate client --sdl schema.graphql --operations ops/ --out internal/gen
If you generated the migrations with --graph, pass the same name here —
the compiled SQL names the graph, and a mismatch is only discovered at query
time. See the CLI reference.
func (c *Client) AppendEvent(ctx context.Context, h exec.Handle, in AppendEventInput) error
func (c *Client) ListPeople(ctx context.Context, h exec.Handle, in ListPeopleInput) ([]ListPeoplePerson, error)The handle is the second parameter of every method, and the client opens no connection and holds no pool — so an operation runs in whatever transaction you are already in, and commits with it.
exec.Handle names no driver type. Adapt whichever handle you hold:
exec.Pgx(pool) // *pgxpool.Pool, *pgx.Conn or pgx.Tx
exec.Portable(tx) // anything driver-agnostic, e.g. a dbos.Txexec.PgxQuerier and exec.PortableQuerier are the read-only halves, for a
caller that only runs queries.
exec.Portable is what lets a transaction opened by another framework run
gopgql's statements. Its type parameters are inferred, so a DBOS transaction
goes through unannotated and gopgql names no DBOS type:
dbos.RunAsTransaction(ctx, ds, func(ctx context.Context, tx dbos.Tx) (T, error) {
return client.AppendEvent(ctx, exec.Portable(tx), in)
})The SQL is a const and results are assigned field by field by generated code;
nothing is compiled, parsed or reflected over at run time. A query that cannot
compile — an unknown root field, a selection past the depth ceiling — fails the
generate command, never a request. Generation is deterministic and needs no
database, so go generate ./... && git diff --exit-code is a usable CI gate.
gopgql has two ways to turn a match into the nested GraphQL response, and you pick one when you build the compiler:
// The default: project flat columns and regroup them in Go.
c := compiler.New(doc)
// Or: let PostgreSQL assemble the response with json_build_object / json_agg
// and return it as a single `response` column.
c := compiler.New(doc, compiler.WithShaping(compiler.SQLSide))It is a compiler option rather than a runtime switch because the two emit
different SQL. exec.Query reads the strategy off the compiled query and
dispatches, so its signature is the same either way and every existing caller
keeps working. The MATCH pattern, its predicates and its bind parameters are
identical under both — only the projection around them changes.
A depth-d, fan-out-f query ships f^d rows to the client under Go-side
shaping and exactly one row under SQL-side shaping. Where a query branches, the
flat statement LEFT JOINs the branches and a parent with m and n children
yields m×n rows, while the SQL-side statement aggregates each branch to an
array before the join. Measurements are in
docs/benchmarks.md; the default is Go-side, and the
numbers are what a change to that would have to argue from.
Both produce byte-identical responses — where that means something specific, and worth reading before relying on it:
The response is the
map[string]anyreturned byexec.Query. Its canonical encoding isshape.Encode, which isencoding/jsonover that value. Two strategies produce byte-identical responses whenshape.Encodeof each returns equal bytes.
It does not mean the bytes PostgreSQL sends equal the bytes gopgql writes.
They do not: json_build_object emits {"k" : v} in argument order, and
jsonb_build_object additionally sorts keys by length-then-bytes and drops
duplicates. That divergence stops where the SQL-side path decodes the database's
JSON into the same Go value the Go-side path builds — one encoder writes both,
so identity holds by construction rather than by a passing test. test/parity
re-runs every query scenario from every earlier milestone under both strategies
against a real postgres:19beta2 and compares the encoded bytes, list order
included.
One consequence worth knowing: under SQL-side shaping a projected scalar gopgql
has no canonical form for — a @column(type: "interval"), say — is a typed
*compiler.UnshapeableScalarError at compile time. Go-side shaping keeps
accepting it, because it makes no cross-strategy promise about it.
Everything above reasons from the SDL alone, which is sound only while nobody
alters the database out of band. gopgql conform is the check on that
assumption — it reads the property graph back out of a live database and reports
how it differs:
gopgql conform --sdl schema.graphql --dsn "$GOPGQL_DSN"The answer is the exit status, so it gates a pipeline with nothing parsing its
output: 0 the graph matches, 2 it has drifted, 1 the check could not run
at all — an unreachable database and a drifted one call for completely different
next moves. Findings print one per line, naming which side said what:
KIND ELEMENT PROPERTY SDL DATABASE
MissingElement companies - company -
LabelMismatch persons - actor, person human
MissingProperty persons email email -
It compares the property graph — which elements exist, the labels they carry and
the properties they expose. Column defaults, CHECK and UNIQUE constraints,
indexes and column types live in other catalogs and are not compared, so a
clean report means the graph mapping matches the SDL, not that the tables
underneath it do.
gopgql generate --sdl <path> [--sdl <path>…] --dir <dir> [--name <suffix>]
[--graph <name>] [--json-type <type>] [--no-tables] [--no-graph]
gopgql generate client --sdl <path> [--sdl <path>…] --operations <dir> --out <dir>
[--package <name>] [--graph <name>]
gopgql migrate --dsn <url> [--sdl <path>…] [--dir <dir>] [--name <suffix>]
[--graph <name>] [--json-type <type>] [--no-tables] [--no-graph]
gopgql conform --sdl <path> [--sdl <path>…] --dsn <url> [--graph <name>]
gopgql version
--sdl, --dsn and --dir fall back to GOPGQL_SDL, GOPGQL_DSN and
GOPGQL_MIGRATIONS; a flag wins over the environment. --dir defaults to
migrations.
--sdl is repeatable, and it also takes a directory whose *.graphql files
are read in sorted order (not recursively). Every document is parsed as one
schema:
gopgql generate --sdl schema/00-dbos.graphql --sdl schema/10-app.graphql --dir migrations
gopgql generate --sdl schema/ --dir migrations # the same thingThe reason to want this is that a property graph can only span two PostgreSQL schemas if one schema describes both — and the boundary between what a service owns and migrates and what it only reads is usually the thing you least want to lose. Splitting the SDL along that boundary keeps it visible without giving up the graph that crosses it.
Splitting is purely editorial: generating from several files produces byte-identical output to generating from those files concatenated, so there is never a reason to concatenate them yourself. The files are merged before anything is resolved, so a type may be referenced before the file declaring it is read — the split follows ownership, not dependency order. What each file keeps is its name, so a parse or validation error points at the file you have to open.
GOPGQL_SDL carries one path or several, separated the way your platform
separates a path list (: on Unix, ; on Windows).
The JSON scalar maps to jsonb. --json-type json moves that default for the
whole schema, and @column(type:) still wins on any column that carries one:
gopgql generate --sdl schema.graphql --json-type json --dir migrationsjsonb is the default because it is what can be indexed and queried. What it
costs is byte-identical round trip — it sorts object keys, drops insignificant
whitespace and keeps only the last of a duplicated key — so a schema on a
round-trip path (a signed payload, a document whose hash is checked) wants
json. Setting it globally is the point: per column, the annotation that was
forgotten is invisible until a stored value has more than one key.
Changing it on a schema that is already deployed generates a real migration —
ALTER TABLE … ALTER COLUMN … TYPE json USING …::json, which keeps the column's
rows. It cannot un-normalise what jsonb already stored, though: only documents
written after the migration round-trip. GOPGQL_JSON_TYPE is the environment
equivalent.
--graph has to match across commands. It names the property graph, and it
defaults to app_graph everywhere it appears. generate bakes that name into
CREATE PROPERTY GRAPH; generate client bakes it into every GRAPH_TABLE it
compiles. Give it to one and not the other and nothing complains — the client
compiles cleanly against a graph that does not exist, and the first thing to
notice is PostgreSQL, at query time, in your process. Neither command can see
the other's output, so pass the same --graph to both (or to neither) and check
it with gopgql conform, which compares the SDL against the graph a live
database actually holds.
--no-tables generates the property graph over tables someone else owns —
gopgql then never reads, diffs or emits anything about a table, so absence from
the SDL is not evidence of absence from the database. --no-graph is the
mirror. Both scope a directory's first generation; after that its own history
decides, and a flag contradicting it is refused. With the tables half on,
gopgql is managing those tables, and a column absent from the SDL is a column it
will remove.
make build # go build ./...
make test # integration suite (needs Docker + postgres:19beta2)
make bench # shaping benchmark; regenerates docs/benchmarks.md
make lint # golangci-lint
make docs # build the WASM playground + docs site into docs/distThe suites under ./test/... boot a real postgres:19beta2 container and have
no skip path. Everything else runs without Docker:
go build ./... && go vet ./...
go test ./compiler/... ./shape/... ./sdl/... ./generator/... ./migrate/... \
./playground/... ./internal/... ./conform/... ./cmd/gopgql/...The playground served at the demo link is docs/:
cd docs && npm install && npm run devnpm run dev and npm run build rebuild gopgql.wasm first, so the page is
never served against a stale module.