Skip to content

Migrating from Hasura to FraiseQL

This guide covers migrating from Hasura (auto-generated GraphQL from database introspection) to FraiseQL (code-first GraphQL backed by hand-written SQL views and PostgreSQL functions).

FeatureHasuraFraiseQL
ApproachDatabase-first (auto-schema from DB)Code-first (schema from Python/TS/Go decorators)
ConfigurationWeb UI + YAMLSDK decorators + TOML
Type SafetyGraphQL generatedNative SDK types (Python, TypeScript, Go)
HostingCloud or self-hostedAny server — Docker, K8s, bare metal
Custom LogicActions + WebhooksPostgreSQL functions (fn_*)
FederationNoYes (multi-database)
PerformanceGoodExcellent (pre-compiled SQL views)
RuntimeNode.js / HaskellRust binary
Learning CurveLow (UI-driven)Medium (code-driven)

Use this table as a translation guide as you work through the migration:

HasuraFraiseQL Equivalent
Track tableCreate SQL view (v_*)
Permissions (YAML)PostgreSQL Row-Level Security via SQL view WHERE clause
Event triggersObservers (@observer decorator)
ActionsSQL functions (fn_*) + mutations
Remote schemasFederation (multi-database in one binary)
Computed fieldsSQL view expressions
Relationships (YAML)Typed fields on @fraiseql.type backed by SQL JOIN
Hasura Consolefraiseql.toml + SDK decorators

FraiseQL asks you to write views. In exchange:

  • Your database schema is decoupled from your API. Hasura exposes tables directly — a table rename breaks the API. FraiseQL exposes views — a table rename is invisible to clients.
  • You control exactly what the API exposes. No _aggregate, _by_pk, _stream variants you didn’t ask for.
  • Complex queries are SQL, not YAML permission rules or nested filter objects.
  • Row-level filtering uses SQL WHERE clauses instead of Hasura’s permission rule engine.

The migration involves creating v_* views that match your existing data model. For a simple case this is 30–60 minutes of SQL. For a complex case with permissions it replaces Hasura’s YAML rules with SQL WHERE conditions on the view.

Hasura auto-generates a GraphQL schema by introspecting your database tables. Every column and relationship becomes a queryable field automatically.

FraiseQL uses hand-written SQL views (v_*) for reads and PostgreSQL functions (fn_*) for writes. The Rust binary serves a GraphQL API derived from your decorator-annotated schema types — giving you explicit control over exactly what data is exposed and how it is shaped.

  1. Export Hasura Configuration

    Terminal window
    # Get current Hasura metadata
    hasura metadata export
    # Review generated metadata
    cat metadata/databases/default/tables/
  2. Map Hasura Tables to FraiseQL Types

    Each Hasura table becomes a FraiseQL @fraiseql.type. Relationships defined in Hasura YAML become typed fields on the class.

    metadata/databases/default/tables/user.yaml
    table:
    name: user
    schema: public
    select_permissions:
    - role: user
    permission:
    columns: [id, email, name]
    filter:
    id:
    _eq: X-Hasura-User-Id
    relationships:
    - name: posts
    type: array
    using:
    foreign_key_constraint_on:
    column: user_id
    table:
    name: post
    schema: public
  3. Replicate Permissions via SQL Views

    Hasura row-level permissions become WHERE clauses in your PostgreSQL views. The FraiseQL Rust binary passes the authenticated user’s context as a PostgreSQL session variable.

    select_permissions:
    - role: user
    permission:
    columns: [id, name, email]
    filter:
    id:
    _eq: X-Hasura-User-Id

    Verify your permission migration works correctly by testing with both an admin token and a viewer token:

    Terminal window
    # Admin role: full access — should return all users
    curl -X POST http://localhost:8080/graphql \
    -H "Authorization: Bearer $ADMIN_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"query": "{ users { id name email } }"}' | jq '.data.users | length'
    # Expect: total user count (e.g. 42)
    # Viewer role: filtered access — should return only the viewer's own profile
    curl -X POST http://localhost:8080/graphql \
    -H "Authorization: Bearer $VIEWER_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"query": "{ myProfile { id name email } }"}' | jq '.data.myProfile'
    # Expect: single user object matching the viewer's own account
    # Viewer role: cross-user access — should be filtered out by the SQL view
    curl -X POST http://localhost:8080/graphql \
    -H "Authorization: Bearer $VIEWER_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"query": "{ user(id: \"other-user-id\") { id name } }"}' | jq '.data.user'
    # Expect: null (filtered by SQL view WHERE clause — not an authorization error)
  4. Migrate Custom Actions to PostgreSQL Functions

    Hasura Actions are webhook-based — they call an external HTTP endpoint. FraiseQL replaces these with PostgreSQL functions (fn_*) or observer hooks that trigger side effects.

    actions:
    - name: sendEmail
    definition:
    kind: action
    arguments:
    - name: email
    type: String!
    - name: message
    type: String!
    output_type: ActionOutput
    webhook_url: https://myapp.com/send-email
    timeout: 30
  5. Migrate Subscriptions to NATS

    subscription OnUserCreated {
    user(event: insert) {
    id
    name
    email
    }
    }
  6. Update Client Queries

    FraiseQL uses a slightly different filter syntax from Hasura. Update your client queries accordingly.

    query {
    user(where: {
    email: { _eq: "alice@example.com" }
    posts: { title: { _ilike: "hello%" } }
    }) {
    id
    email
    }
    }

    Ordering and pagination syntax is compatible:

    query {
    users(order_by: { created_at: desc }) {
    id
    name
    }
    }

    Pagination is identical in both:

    query {
    users(limit: 10, offset: 20) {
    id
    name
    }
    }
  7. Performance Test and Decommission

    Once all client queries are migrated and validated, decommission Hasura. FraiseQL’s pre-compiled SQL views typically deliver 3-5x better performance than Hasura’s auto-generated queries.

Hasura auto-generates SQL from the GraphQL query at runtime. This is flexible but can produce suboptimal queries, especially for nested relationships.

FraiseQL uses hand-written SQL views that you optimize yourself. The Rust binary executes pre-compiled queries — no runtime SQL generation.

Hasura query (10 users with posts):
users: 1 query
user[0].posts: 1 query (N+1!)
user[1].posts: 1 query
... (8 more)
Total: 11 queries
FraiseQL query (10 users with posts):
users: 1 query (v_user)
posts (batched): 1 query (v_post WHERE user_id IN (...))
Total: 2 queries
Hasura CloudFraiseQL (Self-hosted)
GraphQL enginePaid tier required for production featuresOpen source (Apache 2.0)
Server hostingIncluded in cloud planYour own server or cloud VM
DatabaseExtra (managed database add-on)Existing or separate managed DB

FraiseQL is self-hosted, so your total cost depends on your infrastructure. For many teams, running a single Rust binary alongside an existing PostgreSQL instance results in significantly lower costs than a managed GraphQL cloud service, but your mileage will vary.

  • Export Hasura metadata and config
  • Document all tables, relationships, and permissions
  • Map tables to FraiseQL types
  • Write SQL views (v_*) replicating Hasura’s select permissions
  • Write PostgreSQL functions (fn_*) replacing Hasura actions
  • Define FraiseQL decorator types and queries
  • Migrate subscriptions to NATS
  • Update client query syntax
  • Performance test
  • Decommission Hasura

Prisma Migration

Migrating from Prisma ORM.

Read guide

Apollo Migration

Migrating from Apollo Server.

Read guide

REST Migration

Migrating from REST APIs.

Read guide

Getting Started

Learn FraiseQL fundamentals from scratch.

Read guide

  1. Export and compare schemas:

    Terminal window
    # Export Hasura metadata
    hasura metadata export
    # Compile FraiseQL schema
    fraiseql compile --output schema.fraiseql.json
    # Compare type counts
    cat metadata/databases/default/tables/*.yaml | grep "^table:" | wc -l
    cat schema.fraiseql.json | jq '.types | length'
  2. Test query parity:

    Terminal window
    # Hasura query
    curl -X POST http://localhost:8080/v1/graphql \
    -H "Content-Type: application/json" \
    -H "X-Hasura-Admin-Secret: secret" \
    -d '{"query": "{ users { id name } }"}' \
    > hasura_result.json
    # FraiseQL query
    curl -X POST http://localhost:8080/graphql \
    -H "Content-Type: application/json" \
    -d '{"query": "{ users { id name } }"}' \
    > fraiseql_result.json
    # Compare field structure
    diff <(jq '.data.users[0] | keys' hasura_result.json) \
    <(jq '.data.users[0] | keys' fraiseql_result.json)
  3. Verify RLS equivalence:

    Terminal window
    # Test with user token
    curl -X POST http://localhost:8080/graphql \
    -H "Authorization: Bearer $USER_TOKEN" \
    -d '{"query": "{ users { id email } }"}'
    # Verify user only sees their own data
    # (same behavior as Hasura with permissions)
  4. Test event triggers → observers:

    Terminal window
    # Create a record
    curl -X POST http://localhost:8080/graphql \
    -d '{"query": "mutation { createUser(...) { id } }"}'
    # Check webhook was called (in FraiseQL observer)
    tail -f /var/log/fraiseql/observers.log
  5. Performance comparison:

    Terminal window
    # Benchmark Hasura
    ab -n 1000 -c 10 -p query.json \
    -H "X-Hasura-Admin-Secret: secret" \
    http://localhost:8080/v1/graphql
    # Benchmark FraiseQL
    ab -n 1000 -c 10 -p query.json \
    http://localhost:8080/graphql

Symptoms: Users can see all data instead of just their own.

Solution:

  1. Verify RLS is enabled on tables:

    SELECT relname, relrowsecurity FROM pg_class WHERE relname LIKE 'tb_%';
  2. Check policy exists:

    SELECT * FROM pg_policies WHERE tablename = 'tb_user';
  3. Ensure tenant_id is being set:

    # Check context in resolver
    print(ctx.tenant_id) # Should not be None

Symptoms: Observers don’t execute when data changes.

Solution:

  1. Verify observer is registered:

    # Check logs during startup
    fraiseql run 2>&1 | grep "observer"
  2. Check observer syntax:

    @fraiseql.observer(
    entity="User", # Must match table name
    event="INSERT"
    )
  3. Test NATS connection:

    Terminal window
    docker-compose exec nats nats server info

Symptoms: Hasura had field-level permissions, FraiseQL exposes everything.

Solution:

  1. Use field-level decorators:

    @fraiseql.type
    class User:
    id: str
    email: str
    # Hide sensitive fields
    password_hash: Annotated[str, fraiseql.field(exclude=True)]
  2. Or create separate views for different roles:

    CREATE VIEW v_user_public AS
    SELECT id, name FROM tb_user;
    CREATE VIEW v_user_admin AS
    SELECT id, name, email, created_at FROM tb_user;

Symptoms: Nested queries return null or empty arrays.

Solution:

  1. Check SQL view has JOIN:

    -- View should JOIN related tables
    SELECT ... FROM tb_user u
    LEFT JOIN tb_post p ON p.user_id = u.id
  2. Verify foreign keys exist:

    SELECT conname, conrelid::regclass, confrelid::regclass
    FROM pg_constraint WHERE contype = 'f';
  3. Test the view directly:

    SELECT data FROM v_user WHERE id = '123';
    -- Should include nested posts

Symptoms: FraiseQL is slower than Hasura.

Solution:

  1. Check for missing indexes (Hasura auto-creates, FraiseQL doesn’t):

    -- List all indexes
    SELECT indexname, tablename FROM pg_indexes
    WHERE tablename LIKE 'tb_%' ORDER BY tablename;
  2. Create necessary indexes:

    CREATE INDEX idx_user_email ON tb_user(email);
    CREATE INDEX idx_post_user_id ON tb_post(user_id);
  3. Analyze query plans:

    EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
    SELECT data FROM v_user WHERE email = 'test@test.com';