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.
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.
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.1PGRST_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.
{
"tables": {
"public.products": {
"name": "Product",
"federation": { "shared": true },
"roots": {
"select": "products",
"select_by_pk": "product"
}
}
}
}{
"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:
type Product @key(fields: "id") {
id: Int!
name: String! @shareable
price_cents: Int! @shareable
}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.
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/graphqlsupergraph:
listen: 0.0.0.0:4000
headers:
all:
request:
- propagate:
named: authorizationrover supergraph compose --config supergraph.yaml --output supergraph.graphql
router --config router.yaml --supergraph supergraph.graphqlThe 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:
{
catalog_products {
name
price_cents
reviews {
rating
body
}
}
}{
"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:
{
_entities(representations: [
{ __typename: "Product", id: 1 },
{ __typename: "Product", id: 99 }
]) {
... on Product { reviews { rating } }
}
}{
"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:
{
reviews_reviews {
rating
product {
name
price_cents
}
}
}{
"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:
"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.
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:4000The 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,@externaland@override;- repeatable and nested keys;
@tag,@inaccessible,@composeDirectiveand@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:
| Tool | As a Federation subgraph, October 2026 |
|---|---|
| Hasura v2 | A 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 |
| GraphJin | Serves Federation 2 SDL, but _entities is not implemented yet, so it composes and cannot resolve an entity another subgraph references. Source |
| Grafbase Postgres extension | Runs inside Grafbase Gateway as a virtual subgraph; there is no server for Apollo Router to call. Source |
| Supabase pg_graphql | No 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.