GraphQL API
A GraphQL API generated from your database schema, in the dialect Hasura speaks. A client generated against Hasura — its queries, its codegen output, its endpoint — points at this server unchanged.
Endpoints
POST /v1/graphqlExecute a query or mutation. This is where a Hasura client sends them, and for most generated clients the only address they can be told about.
GET /v1/graphqlGraphQL Playground (interactive IDE)
GET /v1/graphql/wsSubscriptions over WebSocket, using the graphql-transport-ws protocol
POST /v1alpha1/graphqlThe address Hasura served before /v1, for a client old enough that it cannot be repointed
POST /api/graphqlThe same surface, for anything already pointed here
Shape of the schema
For a table author, with a to-many relationship articles:
| Root field | What it answers |
|---|---|
author(where:, order_by:, distinct_on:, limit:, offset:) | the rows |
author_by_pk(id: 1) | one row, or null |
author_aggregate(where: …) | aggregate { count sum { … } } and nodes { … } |
insert_author(objects:, on_conflict:) | affected_rows and returning { … } |
insert_author_one(object:, on_conflict:) | the row written |
update_author(where:, _set:, _inc:, …) | affected_rows and returning { … } |
update_author_by_pk(pk_columns: {id: 1}, _set:) | the row written |
update_author_many(updates: [{where, _set, …}]) | one mutation response per update |
delete_author(where:) | affected_rows and returning { … } |
delete_author_by_pk(id: 1) | the row removed |
The root types are named query_root, mutation_root and subscription_root, unless Apollo Federation is enabled, where they are Query, Mutation and Subscription. The subscription root mirrors the query root, and each of its fields is a live query.
Column types follow the PostgreSQL type. PostgreSQL has no ID, so a text-, integer- or UUID-backed identifier that a client expects as ID is declared with a column_types entry in PGRST_GRAPHQL_METADATA. It changes the GraphQL type — fields, comparisons, write inputs and key arguments — and not how the column is read, bound or cast:
{
"tables": {
"public.author": {
"column_types": { "id": "ID" }
}
}
}Queries
query {
author(
where: { name: { _ilike: "%rust%" } }
order_by: [{ created_at: desc_nulls_last }, { name: asc }]
limit: 10
offset: 20
) {
id
name
articles(where: { published: { _eq: true } }, limit: 5) {
id
title
}
articles_aggregate {
aggregate {
count
}
}
}
}order_by takes a list of single-key objects, because ordering is ordered: {name: asc, id: desc} is one object whose two keys have no defined precedence, and the client that wrote it meant name first.
Filtering
where takes a generated <table>_bool_exp. Every comparison is named for the type it applies to, so an unknown operator or an ill-typed operand is refused by validation rather than by the database.
Any column
_eq _neq _gt _gte _lt _lte _in _nin _is_nullText
_like _nlike _ilike _nilike _similar _nsimilar _regex _iregex _nregex _niregexjson / jsonb
_contains _contained_in _has_key _has_keys_any _has_keys_all _jsonb_path_exists _jsonb_path_match _castltree
_ancestor _descendant _matches _matches_fulltext, and their _any formsPostGIS
_st_contains _st_crosses _st_equals _st_intersects _st_overlaps _st_touches _st_within _st_d_within _st_3d_d_within _castCombine them with _and, _or and _not, and follow a relationship by naming it. A question can also be asked about a whole related set rather than any one row of it:
{
authors(where: { books_aggregate: { count: { predicate: { _gt: 2 } } } }) {
name
}
}Over no related rows at all count is zero and the boolean folds are null — which is how “authors with no books” is written.
Mutations
mutation {
insert_author(
objects: [{ name: "Ada", articles: { data: [{ title: "On engines" }] } }]
on_conflict: { constraint: author_name_key, update_columns: [name] }
) {
affected_rows
returning { id name }
}
update_article_by_pk(pk_columns: { id: 7 }, _set: { published: true }) {
id
}
delete_article(where: { views: { _lt: 10 } }) {
affected_rows
}
}Nested writes, upserts with on_conflict, update_many and the document operators are all supported. A mutation naming several root fields runs them in one transaction: if any fails, none of them happened.
Subscriptions
Each subscription field is a live query — the answer now, and the answer again whenever it changes. Connect to ws://localhost:3000/v1/graphql/ws using the graphql-transport-ws protocol.
subscription {
article(where: { published: { _eq: true } }, order_by: [{ created_at: desc }], limit: 10) {
id
title
}
}The cursor-based half of Hasura’s subscription surface — _stream — is not implemented. See Realtime.
Apollo Federation
The GraphQL API can be an Apollo Federation v2 subgraph, so an Apollo router or gateway can compose it with other services. It is opt-in: with PGRST_GRAPHQL_FEDERATION unset or false, Federation metadata is inert and the generated SDL is unchanged.
PGRST_GRAPHQL_FEDERATION=true
PGRST_GRAPHQL_TYPE_PREFIX=billing # optionalWith it on, the schema gains:
_service { sdl }, the subgraph SDL a router composes from, and_entities(representations:), which resolves entities by key.- The Federation v2 directives. A table’s primary key is its
@key. - The root type names a router expects —
Query,MutationandSubscription— instead of Hasura’squery_rootfamily.
Shared entities
When another subgraph owns the same entity, mark the table as shared in PGRST_GRAPHQL_METADATA:
{
"tables": {
"public.users": {
"federation": { "shared": true }
}
}
}A shared table keeps its unprefixed object type, so two subgraphs can both contribute to it. Its non-key columns, computed fields, relationships and relationship aggregates are marked @shareable, so another subgraph may resolve the same fields; key columns are shareable through @key already.
Type prefix
PGRST_GRAPHQL_TYPE_PREFIX prefixes generated types and root fields, so several Postrust subgraphs over different databases do not collide when composed. A shared entity is the exception for its object type only: with the prefix billing, the table above is still users @key(...), while its root fields and helper types are billing_users, billing_users_bool_exp and billing_users_aggregate.
Keys as ID
When the other subgraphs, or the router’s clients, expect a key as ID, declare it with column_types as described under the shape of the schema above: "column_types": { "id": "ID" }. A key exposed as ID accepts GraphQL’s string or integer spelling in a representation and is normalised to the column’s integer, text or UUID type.
How _entities matches
Representations are checked against each key field’s GraphQL type before any query runs: a field exposed as Int requires a JSON integer, a field exposed as ID accepts either the string or the integer spelling, and a bigint or numeric key accepts a number or the exact decimal string its scalar serialises to. An invalid key is a GraphQL error, without a trip to the database.
Each type’s representations are then resolved in one query, and PostgreSQL decides what matches. A key that PostgreSQL considers equal but spells differently — an uppercase UUID, a citext value in another case, a timestamptz with a different offset — finds its row. A key that matches nothing is a null in its position rather than an error for the whole list. Select permissions apply to _entities exactly as to any other read.
Two subgraphs behind a router
PostgreSQL as an Apollo Federation subgraph walks through two databases, a Postrust subgraph over each and Apollo Router in front, with row-level security deciding what each caller sees through the router. It runs as examples/federation.
A working subgraph
scripts/apollo-federation runs Postrust as the Products subgraph from Apollo’s subgraph compatibility suite, over a PostgreSQL loaded with that suite’s data. The part that matters is the environment:
products:
environment:
DATABASE_URL: postgres://postgres:postgres@database:5432/federation
PGRST_DB_SCHEMAS: public
PGRST_DB_ANON_ROLE: postgres
PGRST_GRAPHQL_FEDERATION: "true"
PGRST_GRAPHQL_METADATA: /config/graphql-metadata.json
PGRST_SERVER_PORT: 4001and the metadata, which names the types the way the target schema does and exposes the keys as ID:
{
"tables": {
"public.products": {
"name": "Product",
"column_types": { "id": "ID" },
"roots": { "select_by_pk": "product" }
},
"public.product_dimensions": {
"name": "ProductDimension",
"federation": { "shared": true }
}
}
}Start it with docker compose -f scripts/apollo-federation/compose.yaml up --build --wait from the repository root, then resolve a product as an entity, the way a router would:
curl http://localhost:4001/v1/graphql \
-H 'content-type: application/json' \
--data '{"query":"query { _entities(representations: [{__typename: \"Product\", id: \"apollo-federation\"}]) { ... on Product { id sku } } }"}'A router is given the subgraph at /v1/graphql and reads its SDL from _service. The fixture’s README also shows how to run Apollo’s suite against it locally; the same suite runs in CI, and a required-capability failure fails the build.
Not supported yet: keys other than the primary key (so no repeatable or nested keys), extended and external fields, @requires, @provides, @override, @tag, @inaccessible, @composeDirective, @interfaceObject and federated tracing. The fixture keeps them in its target schema on purpose, so they show in the suite’s report as optional failures. The suite’s @shareable check fails too, because it looks for @shareable on the type and Postrust puts it on each non-key field; the two compose the same way.
Authentication
A caller holding the admin secret is an administrator, and an administrator may ask to be treated as someone else. Other x-hasura-* headers become session variables that a row-level policy can read.
curl localhost:3000/v1/graphql \
-H 'X-Hasura-Admin-Secret: shh' \
-H 'X-Hasura-Role: user' \
-H 'X-Hasura-User-Id: 1' \
-d '{"query":"{ article { id title } }"}'With a token instead, the role comes from x-hasura-default-role, and an X-Hasura-Role header may select any role listed in the token’s x-hasura-allowed-roles. That list sits inside the signature, so a caller may choose among the identities it was issued and cannot add one.
A token that does not verify — the wrong key, expired, not a bearer token — is refused with invalid-jwt, and one that verifies but names no role, on a server with no anonymous role, with jwt-missing-role-claims. Neither is answered as the anonymous role. A request with no Authorization header at all is the anonymous role, as before.
With no JWT secret configured, there is nothing to verify a token against, and GraphQL ignores the Authorization header — as Hasura does with JWT mode off, and unlike REST, which refuses it. A router forwarding the header to every subgraph relies on that.
With no admin secret configured, x-hasura-* headers are ignored entirely and session variables come only from a verified token. This is a deliberate divergence from Hasura, which treats every caller on an unsecured deployment as an administrator. See Configuration.
Errors
Errors come back in Hasura’s envelope, which client code branches on. There is no data key on failure, and the path names a place in the request rather than in the response.
{
"errors": [
{
"message": "field 'titel' not found in type: 'article'",
"extensions": {
"path": "$.selectionSet.article.selectionSet.titel",
"code": "validation-failed"
}
}
]
}Example request
curl -X POST http://localhost:3000/v1/graphql \
-H "Content-Type: application/json" \
-d '{"query": "{ article(limit: 5) { id title author { name } } }"}'