Postgres GraphQL API Without Running a GraphQL Server

You can get a Postgres GraphQL API without deploying a GraphQL server: the pg_graphql extension builds the schema from your tables and foreign keys and answers queries inside the database. On StitchDB you turn it on once in Studio, and every project then answers GraphQL at https://<project-ref>.p.stitchdb.com/graphql/v1, with the same API keys and the same Row Level Security as the REST API. No resolvers, no extra service, no schema file to keep in sync.

This guide covers turning it on, the shape of the generated schema, queries and mutations, paging, clients and the limits worth knowing.

#Why GraphQL from Postgres directly

The usual way to add GraphQL to a Postgres app is a separate server that introspects the database, or a hand-written schema with resolvers. Both work, and both add something to deploy, secure and keep in sync. With an extension, the database is the GraphQL server:

  • The schema follows your tables, views and foreign keys on its own. A new column appears in GraphQL right after the migration.
  • Authorization is the same Row Level Security that guards REST, Realtime and Storage. There is no second permission system to configure.
  • A nested query is planned by Postgres, so a list of todos with their owners does not turn into one query per row.

The trade-off is control. You cannot write a custom resolver that calls a third-party API in the middle of a query. For that, use an Edge Function or a Postgres function, which shows up as a field.

#Turn it on

In Studio open API › GraphQL and choose Turn on GraphQL. Only owners and admins can do this. It installs the pg_graphql extension and restarts the REST API for a few seconds. Turning it off later stops the endpoint; your tables and data are not touched.

Then send a query:

Terminal
curl https://<project-ref>.p.stitchdb.com/graphql/v1 \
  -H "apikey: $STITCHDB_ANON_KEY" \
  -H "Authorization: Bearer $USER_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"query":"{ todosCollection(first: 10, orderBy: [{ id: DescNullsLast }]) { edges { node { id task } } } }"}'

Every request is a POST with a JSON body of query, and optionally variables and operationName. The answer is the usual { "data": …, "errors": [...] }, with HTTP 200 even when the query has errors, so check errors in your client. Leave out Authorization to query as an anonymous visitor; with a signed-in user's access token, policies see auth.uid().

#What the generated schema looks like

Take this table:

SQL
create table public.todos (
  id bigint generated always as identity primary key,
  user_id uuid not null default auth.uid() references auth.users on delete cascade,
  task text not null,
  is_complete boolean not null default false,
  created_at timestamptz not null default now()
);
Your schemaIn GraphQL
Table todostodosCollection with filter, orderBy, first / after
Insert, update, deleteinsertIntotodosCollection, updatetodosCollection, deleteFromtodosCollection
Foreign keyA nested field on both sides: one row, or a collection
Stable function public.search_todos(term text)A query field
Volatile functionA mutation
Primary keyA global nodeId and a node(nodeId:) lookup

Names keep the table's spelling. If you prefer Todos and insertIntoTodosCollection, turn on name inflection once:

SQL
comment on schema public is '@graphql({"inflect_names": true})';

Only the public schema is part of the API, as with REST. A table appears for a role only when that role has privileges on it, so if anon can only read a table, the anonymous schema has no insert mutation for it.

#Queries: filters, ordering and relations

Collections follow the Relay connection style: edges with a node per row, and pageInfo for paging.

JavaScript
const OPEN_TODOS = /* GraphQL */ `
  query OpenTodos($after: Cursor) {
    todosCollection(
      filter: { is_complete: { eq: false } }
      orderBy: [{ created_at: DescNullsLast }]
      first: 20
      after: $after
    ) {
      pageInfo { endCursor hasNextPage }
      edges {
        node {
          id
          task
          created_at
        }
      }
    }
  }
`

Without orderBy, results come back by primary key ascending. For the next page, pass the previous endCursor as after. Cursor paging stays fast on deep pages, unlike a large offset.

Relations follow foreign keys. If profiles has a foreign key to auth.users and todos references profiles, you can ask for both in one request, and Postgres resolves the whole tree in one go. Row Level Security applies at every level of the tree, so nested rows a user may not see are left out.

#Mutations

Insert one or many rows and get them back:

JavaScript
const ADD_TODOS = /* GraphQL */ `
  mutation {
    insertIntotodosCollection(objects: [{ task: "Write the GraphQL guide" }, { task: "Ship it" }]) {
      affectedCount
      records { id task }
    }
  }
`

user_id is filled by its default, auth.uid(), so the client never sends it, and the policy's with check makes sure it cannot be spoofed.

Updates and deletes take a filter and an atMost guard:

JavaScript
const COMPLETE_TODO = /* GraphQL */ `
  mutation {
    updatetodosCollection(
      set: { is_complete: true }
      filter: { id: { eq: 42 } }
      atMost: 1
    ) {
      affectedCount
      records { id is_complete }
    }
  }
`

atMost defaults to 1 in pg_graphql. If more rows match than atMost allows, the mutation fails with an error instead of changing them. That is a useful safety net: a filter typo does not complete every todo in the table.

#Security: the same rules as REST

GraphQL does not get its own permission model. Every query and mutation runs as the role of the key and token you sent:

  • anon without a token, authenticated with a signed-in user's token.
  • Row Level Security policies apply exactly as on the REST API.
  • Requests with the service_role key bypass Row Level Security; use that key only on servers.
SQL
alter table public.todos enable row level security;

create policy "Users manage their own todos" on public.todos
  for all to authenticated
  using (user_id = auth.uid())
  with check (user_id = auth.uid());

With this policy, todosCollection returns only the caller's rows, and an update with someone else's id affects zero rows. If you have not written policies yet, start with the Row Level Security guide and run the Exposure check in Studio, which tests what an anonymous visitor and a random signed-in user can read and change.

#Clients

Apollo, urql, graphql-request and GraphiQL work with the URL. Set the apikey header, and Authorization for signed-in users:

JavaScript
const res = await fetch('https://<project-ref>.p.stitchdb.com/graphql/v1', {
  method: 'POST',
  headers: {
    apikey: anonKey,
    Authorization: `Bearer ${session.access_token}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ query: '{ todosCollection(first: 10) { edges { node { id task } } } }' }),
})
const { data, errors } = await res.json()

Several StitchDB SDKs have a GraphQL helper that sends the right headers for you. In Python:

python
res = stitchdb.graphql("query($n: Int) { todosCollection(first: $n) { edges { node { id } } } }", {"n": 10})
print(res["data"], res.get("errors"))

In Dart:

dart
final r = await stitchdb.graphql(
    r'query($n: Int) { todosCollection(first: $n) { edges { node { id title } } } }',
    variables: {'n': 10});
if (r.hasErrors) print(r.errors.first.message);

In Swift, graphql never throws; failures are in error:

swift
let answer = await stitchdb.graphql("query { todosCollection(first: 5) { edges { node { id task } } } }")
if let error = answer.error { print(error.message) }
let data = try answer.decode(as: MyData.self)

Kotlin has stitchdb.graphql(...) with decode<MyData>(), and C# has stitchdb.GraphQL(...). Code generators that read a schema with a standard introspection query work too, so you can generate typed hooks for React from the live endpoint. See the SDK overview.

#Limits

PlanRequests per secondQuery timeout
Free508 s
Pro1,00030 s
Scale2,00060 s

GraphQL requests count as API requests of your plan and share its per-second limit and statement timeout (values as of November 2026; see API limits and pricing). A collection returns at most 30 rows per page by default. Page with first and after, or raise the cap:

SQL
comment on schema public is '@graphql({"max_rows": 100})';

inflect_names and max_rows are set with the same comment, so if you want both, put both keys in one @graphql({...}) object. One more gap to know: the rate limit rules you add under API › Rate limits do not apply to /graphql/v1 yet. If you need per-user limits on writes, enforce them in SQL with ratelimit.enforce() in a trigger, which works for any API.

#GraphQL or REST?

Both are generated from the same schema and protected by the same policies, so you can use both in one app:

  • GraphQL suits screens that need nested data with exact fields, teams already on Apollo or urql, and typed client generation from introspection.
  • REST suits simple CRUD, server scripts, cURL, CSV export and HTTP caching of GET requests, and it is what the StitchDB JavaScript SDK's from() uses. Read Instant REST API from your Postgres schema for the details.

Realtime subscriptions are a separate feature in both cases: use Realtime channels for live changes rather than GraphQL subscriptions.

#FAQ

#Is pg_graphql the same as Hasura or PostGraphile?

No. Those run as their own servers next to Postgres. pg_graphql is a Postgres extension that resolves queries inside the database, so there is no extra service to host.

#Can I add custom resolvers?

Not in the usual sense. Write a Postgres function in public; stable functions become query fields and volatile ones become mutations. For calls to outside services, use an Edge Function.

#Does the JavaScript SDK have a GraphQL method?

The JavaScript SDK documentation does not list one, so use fetch or a GraphQL client with the apikey and Authorization headers. Python, Dart, Swift, Kotlin and C# have helpers.

#Can I get a total count?

pg_graphql has a totalCount field on collections, but it is off by default because counting large tables is expensive. It is enabled with a comment directive on the table.

Turn your schema into a GraphQL API in minutes: create a free project, then read the GraphQL docs.