Blog
  • Apollo Federation
  • PostgreSQL
  • GraphQL

PostgreSQL as an Apollo Federation subgraph, without writing resolvers

Two PostgreSQL databases, two generated subgraphs, one Apollo Router: what a resolver-less subgraph has to do, a worked example with row-level security carried through the router, and how the other tools that do this compare as of October 2026.

The Postrust team

8 min read

Putting PostgreSQL behind Apollo Router usually means a GraphQL service with a resolver for every type and field, and an entity resolver for every type another subgraph can reference. For tables that already say what they are — columns, keys, foreign keys — that service is mostly a transcription of the catalog. This post shows the alternative: subgraphs generated from the database, composed and queried through Apollo Router, with row-level security still deciding what each caller sees.

We make Postrust, so it is the tool used here. Other tools that do something similar are compared at the end, with sources.

What a subgraph has to do

A Federation v2 subgraph answers two fields beyond its own schema:

  • _service { sdl } returns its schema, with Federation directives, for the router to compose with the others.
  • _entities(representations: [...]) takes a list of keys — { __typename: "Product", id: 1 } — and returns those objects, so the router can fetch the fields this subgraph owns for objects another subgraph found.

Everything a generator needs for this is in PostgreSQL’s catalog. A table is a type, a primary key is an @key, a foreign key is a relationship, and _entities is a WHERE key IN (...). What the catalog cannot say is which tables are the same entity across databases; that part is configuration.

Two databases, one graph

The example has a catalog database that knows what products are, and a reviews database that knows what people said about them. The reviews database holds products only by key. Reviews are moderated: anonymous callers see published ones, and an editor sees all of them, enforced by a row-level security policy rather than by anything in the API layer.

reviews.sql (excerpt)
CREATE TABLE products (id int PRIMARY KEY);
CREATE TABLE reviews (
  id int PRIMARY KEY,
  product_id int NOT NULL REFERENCES products,
  rating int NOT NULL,
  body text NOT NULL,
  published boolean NOT NULL DEFAULT false
);

ALTER TABLE reviews ENABLE ROW LEVEL SECURITY;
CREATE POLICY published ON reviews FOR SELECT TO web_anon USING (published);
CREATE POLICY everything ON reviews FOR SELECT TO editor USING (true);

Each database gets its own Postrust. Both log in as an authenticator role with no privileges of its own, which becomes the role the caller’s token names for each request (web_anon without one) — the setup PostgREST recommends.

the catalog subgraph
docker run -p 3001:3000 \
  -e DATABASE_URL=postgres://authenticator:authenticator@catalog-db:5432/postgres \
  -e PGRST_DB_ANON_ROLE=web_anon \
  -e PGRST_JWT_SECRET=an-example-secret-of-at-least-32-characters \
  -e PGRST_GRAPHQL_FEDERATION=true \
  -e PGRST_GRAPHQL_TYPE_PREFIX=catalog \
  -e PGRST_GRAPHQL_METADATA=/config/metadata.json \
  -v ./catalog.json:/config/metadata.json:ro \
  postrust/postrust:2.3.1

PGRST_GRAPHQL_FEDERATION turns on _service, _entities and the Federation directives. PGRST_GRAPHQL_TYPE_PREFIX prefixes every generated type and root field, so two subgraphs generated from tables of the same name do not collide when composed. The metadata says the one thing the catalog cannot: that products in both databases is the same entity.

catalog.json
{
  "tables": {
    "public.products": {
      "name": "Product",
      "federation": { "shared": true },
      "roots": {
        "select": "products",
        "select_by_pk": "product"
      }
    }
  }
}
reviews.json
{
  "tables": {
    "public.products": {
      "name": "Product",
      "federation": { "shared": true },
      "relationships": {
        "reviews_product_id_fkey": "reviews"
      }
    },
    "public.reviews": {
      "name": "Review",
      "roots": {
        "select": "reviews",
        "select_by_pk": "review"
      },
      "relationships": {
        "reviews_product_id_fkey": "product"
      }
    }
  }
}

A shared table keeps its unprefixed type name, Product, in both subgraphs, so the router sees one entity contributed to by two services. Each subgraph serves it with the fields its own database has:

catalog: _service { sdl } (excerpt)
type Product @key(fields: "id") {
  id: Int!
  name: String! @shareable
  price_cents: Int! @shareable
}
reviews: _service { sdl } (excerpt)
type Product @key(fields: "id") {
  id: Int!
  reviews(...): [reviews_Review!]! @shareable
  reviews_aggregate(...): ... @shareable
}

Every non-key field of a shared type is marked @shareable, which lets either subgraph resolve it if both happen to have it. Here they don’t, so each field has exactly one owner.

Composing and routing

Rover reads each subgraph’s SDL from _service and composes the supergraph; Apollo Router serves it.

supergraph.yaml
federation_version: =2.11.2
subgraphs:
  catalog:
    routing_url: http://catalog:3000/v1/graphql
    schema:
      subgraph_url: http://catalog:3000/v1/graphql
  reviews:
    routing_url: http://reviews:3000/v1/graphql
    schema:
      subgraph_url: http://reviews:3000/v1/graphql
router.yaml
supergraph:
  listen: 0.0.0.0:4000
headers:
  all:
    request:
      - propagate:
          named: authorization
bash
rover supergraph compose --config supergraph.yaml --output supergraph.graphql
router --config router.yaml --supergraph supergraph.graphql

The headers rule is the part that is easy to miss. Each subgraph verifies the caller’s token itself and runs the query as the role it names, so the router has to pass the token on rather than stop at it.

One query now reads from both databases:

query
{
  catalog_products {
    name
    price_cents
    reviews {
      rating
      body
    }
  }
}
response, anonymous
{
  "data": {
    "catalog_products": [
      {
        "name": "Mechanical keyboard",
        "price_cents": 12900,
        "reviews": [
          { "rating": 5,
            "body": "Best keyboard I have owned." }
        ]
      },
      {
        "name": "USB-C dock",
        "price_cents": 8900,
        "reviews": [
          { "rating": 4,
            "body": "Does what it says." }
        ]
      }
    ]
  }
}

The router asks the catalog for the products, then sends the reviews subgraph one _entities call with every product key it got back. Postrust answers it with one query per type, not one per key, and an unknown key is a null in its position rather than an error for the whole list:

what the router sends, roughly
{
  _entities(representations: [
    { __typename: "Product", id: 1 },
    { __typename: "Product", id: 99 }
  ]) {
    ... on Product { reviews { rating } }
  }
}
reviews subgraph
{
  "data": {
    "_entities": [
      { "reviews": [{ "rating": 5 }] },
      null
    ]
  }
}

Keys are checked against the key column’s type before anything reaches the database: sending id: "1" for an int key is a GraphQL error that names the field and the type it expected.

It works in the other direction too. Starting from a review, the router follows product — a foreign key in the reviews database — to the catalog for the fields only the catalog has:

query
{
  reviews_reviews {
    rating
    product {
      name
      price_cents
    }
  }
}
response, anonymous
{
  "data": {
    "reviews_reviews": [
      { "rating": 5,
        "product": { "name": "Mechanical keyboard",
                     "price_cents": 12900 } },
      { "rating": 4,
        "product": { "name": "USB-C dock",
                     "price_cents": 8900 } }
    ]
  }
}

Who is asking, across subgraphs

The first query, sent with a token whose role claim is editor, returns the review still waiting for moderation as well:

the keyboard's reviews, as an editor
"reviews": [
  { "rating": 5, "body": "Best keyboard I have owned." },
  { "rating": 2, "body": "Pending moderation." }
]

Nothing in the router or the API layer filtered anything. The reviews subgraph ran the _entities query as editor, and PostgreSQL’s policy decided. A token that does not verify — wrong key, expired — is refused as invalid-jwt rather than quietly answered as the anonymous role. Each subgraph can have its own roles and policies; the token is the only thing they share.

Run it

Everything above is in examples/federation: the two databases, both subgraphs, Rover and Apollo Router in one Compose file. Its check.sh sends these queries through the router and checks the answers: both directions, anonymous and as an editor, and a token that does not verify.

bash
git clone https://github.com/postrust/postrust
cd postrust/examples/federation
docker compose up --wait catalog reviews && docker compose up -d router
# then send queries to http://localhost:4000

The same GraphQL API, Federation included, is also served by the AWS Lambda adapter since 2.2.0. The Cloudflare Workers adapter serves the REST API only, so it cannot be a subgraph.

What it does not do

Postrust runs Apollo’s subgraph compatibility suite in CI on every change to its GraphQL code, and the required checks have to pass. The optional ones it does not pass are the Federation features a schema generated from tables has no way to express yet:

  • @requires, @provides, @external and @override;
  • repeatable and nested keys;
  • @tag, @inaccessible, @composeDirective and @interfaceObject;
  • federated tracing.

A shared type’s fields are marked @shareable one by one rather than on the type. The two compose the same way, but the suite’s check looks for the type-level form and fails. Postrust is not on Apollo’s list of compatible subgraphs.

The details are in the Federation documentation.

Other ways to do this

Where other tools that generate GraphQL from PostgreSQL stood on Federation in October 2026, from their own documentation:

ToolAs a Federation subgraph, October 2026
Hasura v2A subgraph, Federation v1 only. Turned on with HASURA_GRAPHQL_ENABLE_APOLLO_FEDERATION, then table by table with apollo_federation_config in metadata. Source
Hasura DDN (v3)Documented as compliant with the subgraph specification, configured per object type and model. The docs name no Federation version, and other subgraphs cannot extend DDN's types. Source
IBM API Connect for GraphQL (formerly StepZen)On Apollo's list of compatible subgraphs; PostgreSQL through @dbquery. Commercial, as SaaS or self-hosted software. Source
PostGraphile@graphile/federation for v4 is a proof of concept, last published in 2021 and unmaintained. We found nothing equivalent for V5. Source
GraphJinServes Federation 2 SDL, but _entities is not implemented yet, so it composes and cannot resolve an entity another subgraph references. Source
Grafbase Postgres extensionRuns inside Grafbase Gateway as a virtual subgraph; there is no server for Apollo Router to call. Source
Supabase pg_graphqlNo Federation support; its maintainers have said it is not planned. Source

For how Postrust compares to these tools beyond Federation, see Postrust vs Hasura and Postrust vs PostGraphile.