Skip to main content

Guide

Grafbase vs Hasura vs PostGraphile 2026

Compare Grafbase, Hasura, and PostGraphile for instant GraphQL APIs. Auto-generated schemas, real-time subscriptions, authorization, and which GraphQL.

·PkgPulse Team·
0

TL;DR

Grafbase is the edge-native GraphQL platform — schema-first, edge deployments, built-in auth, connectors to databases and APIs, TypeScript resolvers, real-time with SSE. Hasura is the instant GraphQL engine — auto-generates GraphQL from PostgreSQL, real-time subscriptions, authorization with row-level security, remote schemas, event triggers. PostGraphile is the PostgreSQL GraphQL server — auto-generates a performant GraphQL API from your database schema, plugins, smart comments, zero-config. In 2026: Grafbase for edge-first GraphQL, Hasura for instant Postgres-backed GraphQL, PostGraphile for pure PostgreSQL-to-GraphQL.

Key Takeaways

  • Grafbase: Edge-deployed, schema-first, TypeScript resolvers, connectors
  • Hasura: hasura-cli — instant GraphQL from Postgres, real-time, event triggers
  • PostGraphile: postgraphile ~20K weekly downloads — Postgres-native, plugin system
  • Grafbase deploys at the edge with built-in auth and caching
  • Hasura provides the fastest path from Postgres to production GraphQL API
  • PostGraphile generates the most optimized queries directly from Postgres schema

Grafbase

Grafbase — edge-native GraphQL:

Schema definition

# grafbase/schema.graphql
type Package @model {
  name: String!
  description: String
  downloads: Int!
  version: String!
  tags: [String!]!
  author: Author @relation
  createdAt: DateTime!
}

type Author @model {
  name: String!
  email: String! @unique
  packages: [Package!]! @relation
  bio: String
}

type Query {
  featuredPackages: [Package!]! @resolver(name: "featured")
  searchPackages(query: String!): [Package!]! @resolver(name: "search")
}

type Mutation {
  incrementDownloads(packageId: ID!): Package @resolver(name: "incrementDownloads")
}

TypeScript resolvers

// grafbase/resolvers/featured.ts
export default async function FeaturedResolver(_, __, { g }) {
  const { packageCollection } = await g.query({
    packageCollection: {
      __args: {
        first: 10,
        orderBy: { downloads: "DESC" },
      },
      edges: {
        node: {
          id: true,
          name: true,
          description: true,
          downloads: true,
          version: true,
          tags: true,
          author: {
            name: true,
          },
        },
      },
    },
  })

  return packageCollection.edges.map((e: any) => e.node)
}

// grafbase/resolvers/search.ts
export default async function SearchResolver(
  _,
  { query }: { query: string },
  { g }
) {
  const { packageSearch } = await g.query({
    packageSearch: {
      __args: {
        query,
        first: 20,
        fields: ["name", "description"],
      },
      edges: {
        node: {
          id: true,
          name: true,
          description: true,
          downloads: true,
          version: true,
        },
        score: true,
      },
    },
  })

  return packageSearch.edges.map((e: any) => e.node)
}

// grafbase/resolvers/incrementDownloads.ts
export default async function IncrementDownloadsResolver(
  _,
  { packageId }: { packageId: string },
  { g }
) {
  const pkg = await g.query({
    package: {
      __args: { by: { id: packageId } },
      id: true,
      downloads: true,
    },
  })

  if (!pkg.package) throw new Error("Package not found")

  const updated = await g.mutation({
    packageUpdate: {
      __args: {
        by: { id: packageId },
        input: { downloads: pkg.package.downloads + 1 },
      },
      package: {
        id: true,
        name: true,
        downloads: true,
      },
    },
  })

  return updated.packageUpdate.package
}

Configuration and auth

// grafbase.config.ts
import { g, config, auth } from "@grafbase/sdk"

const provider = auth.JWT({
  issuer: "https://auth.example.com",
  secret: g.env("JWT_SECRET"),
})

export default config({
  schema: g,
  auth: {
    providers: [provider],
    rules: (rules) => {
      rules.private()  // All queries require auth by default
    },
  },
  cache: {
    rules: [
      {
        types: ["Package"],
        maxAge: 60,  // Cache for 60 seconds
        staleWhileRevalidate: 300,
      },
    ],
  },
})

Client queries

// Querying Grafbase from a Next.js app:
const GRAFBASE_URL = process.env.GRAFBASE_API_URL!
const GRAFBASE_KEY = process.env.GRAFBASE_API_KEY!

async function graphql<T>(query: string, variables?: Record<string, any>): Promise<T> {
  const response = await fetch(GRAFBASE_URL, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-api-key": GRAFBASE_KEY,
    },
    body: JSON.stringify({ query, variables }),
  })

  const { data, errors } = await response.json()
  if (errors) throw new Error(errors[0].message)
  return data
}

// Fetch packages:
const data = await graphql<{ packageCollection: any }>(`
  query GetPackages($first: Int!) {
    packageCollection(first: $first, orderBy: { downloads: DESC }) {
      edges {
        node {
          id
          name
          description
          downloads
          version
          tags
          author { name }
        }
      }
    }
  }
`, { first: 20 })

// Create package:
const result = await graphql<{ packageCreate: any }>(`
  mutation CreatePackage($input: PackageCreateInput!) {
    packageCreate(input: $input) {
      package {
        id
        name
      }
    }
  }
`, {
  input: {
    name: "react",
    description: "UI library",
    downloads: 25000000,
    version: "19.0.0",
    tags: ["frontend", "ui"],
  },
})

Hasura

Hasura — instant GraphQL on Postgres:

Docker setup

# docker-compose.yml
version: "3.8"

services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: pkgpulse
    volumes:
      - pgdata:/var/lib/postgresql/data

  hasura:
    image: hasura/graphql-engine:v2.40.0
    ports:
      - "8080:8080"
    environment:
      HASURA_GRAPHQL_DATABASE_URL: postgresql://postgres:secret@postgres:5432/pkgpulse
      HASURA_GRAPHQL_ENABLE_CONSOLE: "true"
      HASURA_GRAPHQL_ADMIN_SECRET: myadminsecret
      HASURA_GRAPHQL_JWT_SECRET: '{"type":"HS256","key":"your-jwt-secret-at-least-32-chars"}'
      HASURA_GRAPHQL_UNAUTHORIZED_ROLE: anonymous
    depends_on:
      - postgres

volumes:
  pgdata:

Auto-generated queries

-- Create tables — Hasura auto-generates GraphQL:
CREATE TABLE packages (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL UNIQUE,
  description TEXT,
  downloads INTEGER DEFAULT 0,
  version TEXT,
  tags TEXT[] DEFAULT '{}',
  author_id UUID REFERENCES authors(id),
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE authors (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  email TEXT UNIQUE NOT NULL,
  bio TEXT
);
# Hasura auto-generates these queries and mutations:

# Query packages (with filtering, sorting, pagination):
query GetPackages {
  packages(
    order_by: { downloads: desc }
    limit: 20
    where: { downloads: { _gte: 1000000 } }
  ) {
    id
    name
    description
    downloads
    version
    tags
    author {
      name
      email
    }
    created_at
  }
}

# Aggregate queries:
query PackageStats {
  packages_aggregate(where: { tags: { _contains: ["frontend"] } }) {
    aggregate {
      count
      avg { downloads }
      max { downloads }
      sum { downloads }
    }
  }
}

# Insert:
mutation CreatePackage($object: packages_insert_input!) {
  insert_packages_one(object: $object) {
    id
    name
  }
}

# Update:
mutation IncrementDownloads($id: uuid!) {
  update_packages_by_pk(
    pk_columns: { id: $id }
    _inc: { downloads: 1 }
  ) {
    id
    name
    downloads
  }
}

# Real-time subscription:
subscription WatchPackage($name: String!) {
  packages(where: { name: { _eq: $name } }) {
    name
    downloads
    version
  }
}

Authorization (row-level security)

// Hasura permissions — set via console or metadata:
// packages table — "user" role:
{
  "role": "user",
  "permission": {
    "select": {
      "columns": ["id", "name", "description", "downloads", "version", "tags", "created_at"],
      "filter": {},
      "allow_aggregations": true
    },
    "insert": {
      "columns": ["name", "description", "version", "tags"],
      "check": {
        "author_id": { "_eq": "X-Hasura-User-Id" }
      },
      "set": {
        "author_id": "X-Hasura-User-Id"
      }
    },
    "update": {
      "columns": ["description", "version", "tags"],
      "filter": {
        "author_id": { "_eq": "X-Hasura-User-Id" }
      }
    },
    "delete": {
      "filter": {
        "author_id": { "_eq": "X-Hasura-User-Id" }
      }
    }
  }
}

Event triggers and actions

// Hasura Event Trigger — fires on data changes:
// POST /api/webhooks/package-created
import { NextApiRequest, NextApiResponse } from "next"

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const { event } = req.body

  if (event.op === "INSERT") {
    const pkg = event.data.new
    console.log(`New package: ${pkg.name}`)

    // Send notification, update search index, etc.
    await indexPackageForSearch(pkg)
    await sendSlackNotification(`New package added: ${pkg.name}`)
  }

  if (event.op === "UPDATE") {
    const { old: prev, new: current } = event.data
    if (current.downloads !== prev.downloads) {
      await updateAnalytics(current.id, current.downloads)
    }
  }

  res.json({ success: true })
}

// Hasura Action — custom business logic:
// POST /api/actions/publish-package
export default async function publishPackage(req: NextApiRequest, res: NextApiResponse) {
  const { input: { package_id }, session_variables } = req.body
  const userId = session_variables["x-hasura-user-id"]

  // Custom validation:
  const pkg = await db.query("SELECT * FROM packages WHERE id = $1", [package_id])
  if (pkg.rows[0].author_id !== userId) {
    return res.status(403).json({ message: "Not authorized" })
  }

  // Publish to npm registry:
  await publishToNpm(pkg.rows[0])

  // Update status:
  await db.query(
    "UPDATE packages SET published = true, published_at = NOW() WHERE id = $1",
    [package_id]
  )

  res.json({ success: true, published_at: new Date().toISOString() })
}

Client SDK

// Using Hasura with graphql-request:
import { GraphQLClient, gql } from "graphql-request"

const client = new GraphQLClient("http://localhost:8080/v1/graphql", {
  headers: {
    // Admin access:
    "x-hasura-admin-secret": process.env.HASURA_ADMIN_SECRET!,
    // Or JWT-based user access:
    // Authorization: `Bearer ${userToken}`,
  },
})

// Query with variables:
const { packages } = await client.request(gql`
  query SearchPackages($query: String!, $limit: Int!) {
    packages(
      where: { name: { _ilike: $query } }
      order_by: { downloads: desc }
      limit: $limit
    ) {
      id
      name
      description
      downloads
      version
      tags
      author { name }
    }
  }
`, { query: "%react%", limit: 10 })

// Subscription (via WebSocket):
import { createClient } from "graphql-ws"

const wsClient = createClient({
  url: "ws://localhost:8080/v1/graphql",
  connectionParams: {
    headers: {
      "x-hasura-admin-secret": process.env.HASURA_ADMIN_SECRET!,
    },
  },
})

const unsubscribe = wsClient.subscribe(
  {
    query: `
      subscription WatchPopular {
        packages(order_by: {downloads: desc}, limit: 5) {
          name
          downloads
        }
      }
    `,
  },
  {
    next: (data) => console.log("Update:", data),
    error: (err) => console.error(err),
    complete: () => console.log("Done"),
  }
)

PostGraphile

PostGraphile — PostgreSQL to GraphQL:

Setup

npm install postgraphile
// server.ts — standalone PostGraphile server:
import { postgraphile } from "postgraphile"
import express from "express"

const app = express()

app.use(
  postgraphile(process.env.DATABASE_URL!, "public", {
    watchPg: true,  // Auto-reload on schema changes
    graphiql: true,  // Enable GraphiQL IDE
    enhanceGraphiql: true,
    dynamicJson: true,
    setofFunctionsContainNulls: false,
    ignoreRBAC: false,
    enableQueryBatching: true,
    legacyRelations: "omit",
    // Performance:
    retryOnInitFail: true,
    extendedErrors: ["hint", "detail", "errcode"],
    // Plugins:
    appendPlugins: [
      require("@graphile-contrib/pg-simplify-inflector"),
      require("@graphile/pg-aggregates").default,
    ],
    // JWT auth:
    jwtSecret: process.env.JWT_SECRET!,
    jwtPgTypeIdentifier: "public.jwt_token",
    defaultRole: "anonymous",
  })
)

app.listen(5000, () => {
  console.log("PostGraphile running at http://localhost:5000/graphql")
})

PostgreSQL schema (auto-generates GraphQL)

-- PostGraphile generates GraphQL from your Postgres schema:

CREATE TABLE packages (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL UNIQUE,
  description TEXT,
  downloads INTEGER DEFAULT 0,
  version TEXT,
  tags TEXT[] DEFAULT '{}',
  author_id UUID REFERENCES authors(id),
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE authors (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  email TEXT UNIQUE NOT NULL,
  bio TEXT
);

-- Smart comments — control GraphQL schema:
COMMENT ON TABLE packages IS '@name Package';
COMMENT ON COLUMN packages.author_id IS '@foreignFieldName packages';
COMMENT ON COLUMN packages.created_at IS '@omit create,update';

-- Custom function → becomes a query field:
CREATE FUNCTION search_packages(query TEXT)
RETURNS SETOF packages AS $$
  SELECT * FROM packages
  WHERE name ILIKE '%' || query || '%'
     OR description ILIKE '%' || query || '%'
  ORDER BY downloads DESC
  LIMIT 20;
$$ LANGUAGE sql STABLE;

-- Computed column → becomes a field:
CREATE FUNCTION packages_download_rank(p packages)
RETURNS INTEGER AS $$
  SELECT COUNT(*)::INTEGER
  FROM packages
  WHERE downloads > p.downloads;
$$ LANGUAGE sql STABLE;

-- Custom mutation:
CREATE FUNCTION increment_downloads(package_id UUID)
RETURNS packages AS $$
  UPDATE packages
  SET downloads = downloads + 1
  WHERE id = package_id
  RETURNING *;
$$ LANGUAGE sql VOLATILE;

Auto-generated queries

# PostGraphile auto-generates all of these:

# Query with filtering and pagination:
query GetPackages {
  packages(
    orderBy: DOWNLOADS_DESC
    first: 20
    condition: { }
    filter: { downloads: { greaterThanOrEqualTo: 1000000 } }
  ) {
    nodes {
      id
      name
      description
      downloads
      version
      tags
      downloadRank  # From computed column
      author {
        name
        email
      }
    }
    totalCount
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

# Search (from custom function):
query SearchPackages {
  searchPackages(query: "react") {
    nodes {
      id
      name
      description
      downloads
    }
  }
}

# Mutation (auto-generated):
mutation CreatePackage {
  createPackage(input: {
    package: {
      name: "react"
      description: "UI library"
      downloads: 25000000
      version: "19.0.0"
      tags: ["frontend", "ui"]
    }
  }) {
    package {
      id
      name
    }
  }
}

# Custom mutation (from function):
mutation IncrementDownloads {
  incrementDownloads(input: { packageId: "..." }) {
    package {
      id
      name
      downloads
    }
  }
}

# Aggregates (with plugin):
query PackageStats {
  packages {
    aggregates {
      sum { downloads }
      average { downloads }
      max { downloads }
      distinctCount { name }
    }
  }
}

Row-level security

-- PostGraphile uses Postgres RLS natively:

-- Enable RLS:
ALTER TABLE packages ENABLE ROW LEVEL SECURITY;

-- JWT type for auth:
CREATE TYPE jwt_token AS (
  role TEXT,
  user_id UUID,
  exp BIGINT
);

-- Login function:
CREATE FUNCTION authenticate(email TEXT, password TEXT)
RETURNS jwt_token AS $$
  DECLARE
    account authors;
  BEGIN
    SELECT * INTO account FROM authors WHERE authors.email = authenticate.email;
    IF account.password_hash = crypt(password, account.password_hash) THEN
      RETURN (
        'app_user',
        account.id,
        EXTRACT(EPOCH FROM NOW() + INTERVAL '7 days')
      )::jwt_token;
    END IF;
    RETURN NULL;
  END;
$$ LANGUAGE plpgsql STRICT SECURITY DEFINER;

-- Policies:
CREATE POLICY select_packages ON packages FOR SELECT
  USING (true);  -- Everyone can read

CREATE POLICY insert_packages ON packages FOR INSERT
  WITH CHECK (author_id = current_setting('jwt.claims.user_id')::UUID);

CREATE POLICY update_own_packages ON packages FOR UPDATE
  USING (author_id = current_setting('jwt.claims.user_id')::UUID);

CREATE POLICY delete_own_packages ON packages FOR DELETE
  USING (author_id = current_setting('jwt.claims.user_id')::UUID);

-- Roles:
CREATE ROLE anonymous;
CREATE ROLE app_user;

GRANT SELECT ON packages TO anonymous;
GRANT SELECT, INSERT, UPDATE, DELETE ON packages TO app_user;

Library mode (Express/Fastify)

// Use PostGraphile as Express middleware:
import express from "express"
import { postgraphile, makePluginHook } from "postgraphile"
import PgSimplifyInflectorPlugin from "@graphile-contrib/pg-simplify-inflector"
import PgAggregatesPlugin from "@graphile/pg-aggregates"

const app = express()

// Custom plugin:
const MyPlugin = (builder) => {
  builder.hook("GraphQLObjectType:fields", (fields, build, context) => {
    if (context.scope.isRootQuery) {
      return {
        ...fields,
        serverTime: {
          type: build.graphql.GraphQLString,
          resolve: () => new Date().toISOString(),
        },
      }
    }
    return fields
  })
}

app.use(
  postgraphile(process.env.DATABASE_URL!, ["public"], {
    appendPlugins: [
      PgSimplifyInflectorPlugin,
      PgAggregatesPlugin,
      MyPlugin,
    ],
    subscriptions: true,
    simpleSubscriptions: true,
    websocketMiddlewares: [],
    graphileBuildOptions: {
      pgOmitListSuffix: true,
    },
  })
)

app.listen(5000)

Feature Comparison

FeatureGrafbaseHasuraPostGraphile
Schema approachSchema-firstAuto-generatedAuto-generated
Data sourceBuilt-in + connectorsPostgreSQLPostgreSQL
Query languageGraphQLGraphQLGraphQL
SubscriptionsSSE✅ (WebSocket)✅ (plugin)
AuthorizationBuilt-in JWTRow-level permissionsPostgreSQL RLS
Custom resolversTypeScriptActions (webhooks)SQL functions + plugins
Edge deployment✅ (native)
Caching✅ (edge cache)❌ (external)❌ (external)
Event triggersVia PostgreSQL
Remote schemas✅ (connectors)Via plugins
AggregationsVia resolvers✅ (built-in)✅ (plugin)
Migrations❌ (managed)✅ (CLI)Via PostgreSQL
Console/IDE✅ (Pathfinder)✅ (web console)✅ (GraphiQL)
Self-hosted❌ (SaaS)✅ (Docker)✅ (npm)
Free tier✅ (self-hosted)✅ (MIT license)

When Auto-Generated GraphQL Makes Sense

Instant GraphQL platforms like Hasura and PostGraphile make the most sense when the database schema is the authoritative model for your domain — when the tables, columns, and relationships in PostgreSQL accurately represent the business concepts your API consumers need to query. In this scenario, generating GraphQL from the schema eliminates duplicated type definitions and keeps the API automatically synchronized with schema migrations. The tradeoff is that the generated API exposes the database structure directly, which may leak implementation details to API consumers. If your business domain has concepts that don't map cleanly to individual tables (aggregated views, computed values, multi-step workflows), you'll need custom resolvers or SQL functions to express them — which both Hasura and PostGraphile support, but at the cost of the "instant" part of their value proposition.

Production Performance and N+1 Query Mitigation

Auto-generated GraphQL APIs from database schemas introduce the N+1 query problem by default because each GraphQL field resolver runs independently. Hasura handles this differently from PostGraphile: Hasura uses a query planning approach that joins tables at the SQL level when resolving related objects in a single query, producing efficient SQL for most relationship patterns. PostGraphile's Lookahead feature reads the entire GraphQL query's field selection before executing any SQL, then generates a single optimized SQL query using lateral joins and JSON aggregation — often resolving deeply nested GraphQL queries with a single database round trip. This makes PostGraphile's generated SQL particularly efficient for PostgreSQL, often outperforming hand-written DataLoader patterns for complex relational queries.

Authorization Architecture Comparison

The authorization models differ fundamentally between the three platforms. Hasura's permission rules are declarative JSON objects stored as metadata — select permissions define which columns and rows each role can access, and the X-Hasura-User-Id session variable enables row-level ownership checks without writing SQL. PostGraphile uses PostgreSQL's native Row-Level Security policies, meaning authorization logic lives in the database and applies to all query paths — direct SQL, PostGraphile GraphQL, and any other API accessing the database. This "authorization as database policy" approach is the most secure because it's impossible to bypass at the application level. Grafbase's built-in JWT authorization is evaluated at the edge before the GraphQL query reaches the data layer.

TypeScript Integration and Code Generation

All three platforms benefit from GraphQL code generation for TypeScript client integration. Using graphql-codegen with the schema endpoint generates TypeScript types for all queries, mutations, and subscriptions — run the codegen as part of your CI pipeline so types stay synchronized with the schema. Hasura's schema changes (new tables, altered column types) automatically update the generated GraphQL schema on the next codegen run. PostGraphile provides postgraphile --export-schema-graphql schema.graphql to export the schema for codegen tooling. Grafbase exposes a schema endpoint at its API URL for codegen. For React applications, pair codegen with @tanstack/react-query's typed query hooks or with Apollo Client's useQuery hooks to achieve end-to-end TypeScript from the GraphQL operation to the UI component's data prop.

Self-Hosting Considerations

PostGraphile is the only fully self-hosted option of the three — it runs as a Node.js process or Express middleware on any infrastructure you control, with no external service dependencies beyond your PostgreSQL database. Hasura Community Edition runs on Docker with similar infrastructure autonomy, though Hasura Cloud adds features (caching, rate limiting, observability) that require the managed offering. Grafbase is a managed SaaS platform with no self-hosted option in 2026, making it unsuitable for organizations with data residency requirements or strict vendor dependency policies. For regulated industries (healthcare, finance) that require all data to remain on controlled infrastructure, PostGraphile or self-hosted Hasura are the viable paths.

Migration Paths and Schema Evolution

Evolving a GraphQL schema in production requires careful deprecation management regardless of which platform generates it. Hasura's metadata migration system tracks table tracking, relationship definitions, and permission configurations in YAML files that can be version-controlled — use hasura metadata apply in CI to apply schema changes atomically. PostGraphile's schema evolves automatically with your PostgreSQL schema changes, but changing a column type or removing a table immediately breaks the GraphQL schema — coordinate schema migrations with frontend query updates. Adding the @deprecated smart comment to PostgreSQL columns (COMMENT ON COLUMN packages.old_column IS E'@deprecated Use newColumn instead') propagates the deprecation to the generated GraphQL schema, giving API consumers a grace period before removal.

Federation and Multi-Source GraphQL Composition

Production GraphQL APIs often need to compose data from multiple sources — a primary database, microservices, and third-party APIs — into a unified schema. Hasura's remote schema feature allows merging an existing GraphQL service into Hasura's auto-generated schema, enabling type-safe queries that join database records with external API data in a single GraphQL operation. Hasura v3 extends this with native data connectors (NDCs) for connecting to non-PostgreSQL databases and REST APIs. Grafbase's connector system takes a similar approach — declare connectors in the schema definition to attach external GraphQL services or REST APIs, and Grafbase merges them at the edge with built-in caching. PostGraphile with the postgraphile-plugin-many-to-many and custom plugin hooks can extend the generated schema with custom resolvers that fetch from external sources, but this requires more manual implementation than Hasura or Grafbase's declarative connector systems. For teams already using Apollo Federation, Hasura and PostGraphile can both be configured as subgraphs in a federated supergraph, enabling incremental adoption alongside existing federated services.

When to Use Each

Use Grafbase if:

  • Want edge-deployed GraphQL with built-in caching and auth
  • Prefer schema-first development with TypeScript resolvers
  • Need to unify multiple data sources behind a single GraphQL API
  • Building JAMstack or edge-first applications

Use Hasura if:

  • Want instant GraphQL API from an existing PostgreSQL database
  • Need real-time subscriptions and event triggers out of the box
  • Prefer declarative authorization with row-level permissions
  • Building data-heavy applications where Postgres is the source of truth

Use PostGraphile if:

  • Want a pure PostgreSQL-to-GraphQL layer with maximum performance
  • Prefer PostgreSQL-native features (RLS, functions, computed columns)
  • Need a fully open-source, self-hosted GraphQL server
  • Building applications where database schema drives the API design

Methodology

Feature comparison based on Grafbase (2026), Hasura v2.40.x, and PostGraphile v4.x / Graphile Crystal (March 2026).

Compare GraphQL tools and developer APIs on PkgPulse →

See also: AVA vs Jest and Apollo Router vs Hive Gateway vs WunderGraph, Cal.com vs Calendly vs Nylas (2026).

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.