Skip to content

Schema Definition

FraiseQL lets you define your GraphQL schema in your preferred programming language. Each type maps to a SQL view you’ve written (e.g., User maps to v_user). This page covers the Python syntax; other languages follow similar patterns.

import fraiseql
@fraiseql.type
class User:
"""A user in the system."""
id: str
name: str
email: str
created_at: str

The docstring becomes the GraphQL type description.

Python TypeGraphQL Type
strString!
intInt!
floatFloat!
boolBoolean!
str | NoneString
list[str][String!]!
list[str] | None[String!]
@fraiseql.type
class User:
id: str
name: str
bio: str | None # Optional field
@fraiseql.type
class User:
id: str
tags: list[str] # Required list
nicknames: list[str] | None # Optional list

The Trinity Pattern — Three Identifiers Per Entity

Section titled “The Trinity Pattern — Three Identifiers Per Entity”

FraiseQL’s convention is to give every entity three distinct identifiers. This is called the Trinity Pattern.

pk_user BIGINT -- internal join key, never leaves the database
id UUID -- public API identifier, safe to expose
identifier TEXT -- human-readable (email, username), can change

Most systems conflate internal keys, public identifiers, and human-readable handles. When they’re the same column, you face trade-offs:

If you use…Problem when…
Auto-increment int as public IDUsers can enumerate records by incrementing
UUID as join keyForeign keys are 36 bytes; index bloat at scale
Email as primary keyUser changes email → cascading FK updates

The Trinity Pattern uses each identifier for what it’s good at:

  • pk_user BIGINT — fast joins, compact indexes, never leaves the database
  • id UUID — public API identifier, safe to expose, non-enumerable
  • identifier TEXT — human input (email, username), can change without touching foreign keys

FraiseQL enforces this at compile time when relay=True is set: the view must include pk_{entity} in its JSONB output. The pk is used for cursor generation and stripped before the response leaves the server.

@fraiseql.type
class User:
id: str
name: str
posts: list['Post'] # User has many posts
@fraiseql.type
class Post:
id: str
title: str
author: User # Post belongs to user
@fraiseql.type
class Post:
id: str
title: str
tags: list['Tag']
@fraiseql.type
class Tag:
id: str
name: str
posts: list['Post']

You create the join table (tb_post_tag) in your database schema. FraiseQL maps the relationship through your SQL view, which handles the join.

@fraiseql.type
class Category:
id: str
name: str
parent: 'Category | None'
children: list['Category']

For mutations, define input types:

@fraiseql.input
class CreateUserInput:
name: str
email: str
bio: str | None = None # Optional with default
@fraiseql.input
class UpdateUserInput:
name: str | None = None
email: str | None = None
bio: str | None = None
import enum
class UserRole(enum.Enum):
ADMIN = "admin"
USER = "user"
GUEST = "guest"
@fraiseql.type
class User:
id: str
name: str
role: UserRole
from datetime import datetime
from decimal import Decimal
@fraiseql.type
class Order:
id: ID
total: Decimal
created_at: datetime

Built-in scalar mappings:

Python TypeGraphQL Scalar
datetimeDateTime
dateDate
timeTime
DecimalDecimal
UUIDUUID

FraiseQL creates default query endpoints for each type, mapped to the corresponding SQL view:

  • users — List query (SELECT data FROM v_user)
  • user(id: ID!) — Single record query (SELECT data FROM v_user WHERE id = $1)
  • posts — List query (SELECT data FROM v_post)
  • post(id: ID!) — Single record query (SELECT data FROM v_post WHERE id = $1)

Map a query to any SQL view you own using sql_source:

@fraiseql.query
def active_users(limit: int = 10) -> list[User]:
"""Get all users — FraiseQL adds WHERE filters from arguments automatically."""
return fraiseql.config(sql_source="v_user")
@fraiseql.query
def posts_by_author(author_id: fraiseql.ID) -> list[Post]:
"""Get posts for a specific author."""
return fraiseql.config(sql_source="v_post")

FraiseQL compiles the sql_source (set via fraiseql.config() in the function body) into optimized SQL at build time. At runtime it executes SELECT data FROM v_user WHERE ... and maps the result to the return type.

Transport annotations are optional parameters that extend operations to additional transports. Omit them to serve an operation via GraphQL only. Add rest_path/rest_method to expose via REST. gRPC endpoints are auto-generated when [grpc] is enabled — see gRPC Transport.

@fraiseql.query(
rest_path="/posts", # REST: GET /rest/v1/posts (default path; configure via [rest] path)
rest_method="GET",
)
def posts(limit: int = 10) -> list[Post]:
return fraiseql.config(sql_source="v_post")

The same annotations are available in TypeScript:

@Query({
sqlSource: 'v_post',
restPath: '/posts',
restMethod: 'GET',
})
async function posts(limit: number = 10): Promise<Post[]> {}

Transport annotations are additive — the GraphQL endpoint always remains available regardless of which other transports are configured.

FraiseQL creates default mutation endpoints mapped to SQL functions you write (fn_create_user, fn_update_user, fn_delete_user).

Map a mutation to a SQL function using sql_source and operation:

@fraiseql.mutation(sql_source="fn_publish_post", operation="UPDATE")
def publish_post(id: fraiseql.ID) -> Post:
"""Publish a draft post."""
pass
@fraiseql.mutation(sql_source="fn_archive_user", operation="UPDATE")
def archive_user(id: fraiseql.ID) -> bool:
"""Archive a user and their content."""
pass

The corresponding SQL functions implement the business logic and return a mutation_response:

CREATE FUNCTION fn_publish_post(p_id UUID) RETURNS mutation_response AS $$
DECLARE v_id UUID;
BEGIN
UPDATE tb_post SET is_published = true, published_at = NOW()
WHERE id = p_id RETURNING id INTO v_id;
IF NOT FOUND THEN
RETURN ROW('failed:not_found', 'Post not found', NULL, 'Post', NULL, NULL, NULL, NULL)::mutation_response;
END IF;
PERFORM sync_tv_post_single(v_id);
RETURN ROW('success', 'Post published', v_id, 'Post',
(SELECT data FROM v_post WHERE id = v_id), NULL, NULL, NULL)::mutation_response;
END;
$$ LANGUAGE plpgsql;

Use fraiseql.field(requires_scope=...) to restrict access to sensitive fields:

from typing import Annotated
import fraiseql
@fraiseql.type
class User:
id: str
name: str
# Requires scope to access — returns FORBIDDEN if missing
email: Annotated[str, fraiseql.field(requires_scope="read:User.email")]
# Mask mode: returns null for unauthorized users instead of failing
phone: Annotated[str | None, fraiseql.field(requires_scope="read:User.phone", on_deny="mask")]

To exclude a field entirely (e.g., password_hash), simply omit it from your Python type. Since FraiseQL reads from SQL views, any column not in the view’s JSONB output is never exposed — no decorator needed.

ApproachEffectWhen to use
requires_scope="..."Field in schema, rejected or masked without scopePII, internal fields
on_deny="mask"Field returns null when scope missingOptional exposure
Omit from type + viewField never in GraphQL schemaSecrets, hashes

Computed fields are calculated in the SQL view. Declare them as regular fields in the Python type:

@fraiseql.type
class User:
id: str
first_name: str
last_name: str
full_name: str # Computed in v_user via first_name || ' ' || last_name

There is no @fraiseql.computed decorator. The computation happens in the SQL view’s jsonb_build_object().

@fraiseql.type
class User:
id: str
name: str
username: str = fraiseql.field(deprecated="Use 'name' instead")
schema.ts
import { Type, input, query, enumType } from 'fraiseql';
// Enum
@enumType()
enum UserRole {
ADMIN = 'admin',
USER = 'user',
GUEST = 'guest',
}
// Object types with relationships
@Type()
class User {
id: string;
name: string;
email: string;
role: UserRole;
posts: Post[]; // one-to-many
bio?: string; // optional field
}
@Type()
class Post {
id: string;
title: string;
content: string;
author: User; // many-to-one
tags: Tag[]; // many-to-many
}
@Type()
class Tag {
id: string;
name: string;
posts: Post[];
}
// Input type
@Input()
class CreateUserInput {
name: string;
email: string;
role?: UserRole;
}
schema.go
package schema
// Enum — use a typed string constant group
type UserRole string
const (
UserRoleAdmin UserRole = "admin"
UserRoleUser UserRole = "user"
UserRoleGuest UserRole = "guest"
)
// Object types with relationships
type User struct {
ID string `fraiseql:"id"`
Name string `fraiseql:"name"`
Email string `fraiseql:"email"`
Role UserRole `fraiseql:"role"`
Posts []Post `fraiseql:"posts"` // one-to-many
Bio *string `fraiseql:"bio"` // optional (pointer = nullable)
}
type Post struct {
ID string `fraiseql:"id"`
Title string `fraiseql:"title"`
Content string `fraiseql:"content"`
Author User `fraiseql:"author"` // many-to-one
Tags []Tag `fraiseql:"tags"` // many-to-many
}
type Tag struct {
ID string `fraiseql:"id"`
Name string `fraiseql:"name"`
Posts []Post `fraiseql:"posts"`
}
  1. Define a simple type in your schema:

    import fraiseql
    @fraiseql.type
    class Product:
    """A product in the catalog."""
    id: str
    name: str
    price: float
    in_stock: bool
  2. Compile the schema:

    Terminal window
    fraiseql compile

    You should see output like:

    Compiling schema...
    ✓ Parsed 1 types
    ✓ Generated GraphQL schema
    ✓ Wrote schema.compiled.json
  3. Start FraiseQL and introspect:

    Terminal window
    fraiseql run &
    # Introspect the schema
    curl -X POST http://localhost:8080/graphql \
    -H "Content-Type: application/json" \
    -d '{"query": "{ __type(name: \"Product\") { fields { name type { name } } } }"}'

    Expected response:

    {
    "data": {
    "__type": {
    "fields": [
    {"name": "id", "type": {"name": "String"}},
    {"name": "name", "type": {"name": "String"}},
    {"name": "price", "type": {"name": "Float"}},
    {"name": "inStock", "type": {"name": "Boolean"}}
    ]
    }
    }
    }
  4. Test a relationship:

    @fraiseql.type
    class Category:
    id: str
    name: str
    products: list['Product']
    @fraiseql.type
    class Product:
    id: str
    name: str
    category: Category

    Compile again and verify:

    Terminal window
    fraiseql compile
    fraiseql run
  5. Test queries are generated:

    Terminal window
    curl -X POST http://localhost:8080/graphql \
    -H "Content-Type: application/json" \
    -d '{"query": "{ __schema { queryType { fields { name } } } }"}' | jq '.data.__schema.queryType.fields[].name'

    Should include:

    "product"
    "products"
    "category"
    "categories"

Organize your schema into logical modules:

schema/
├── __init__.py
├── users.py # User, Profile, Authentication
├── products.py # Product, Category, Inventory
├── orders.py # Order, LineItem, Payment
└── common.py # Shared types, scalars

Always include docstrings for complex types:

@fraiseql.type
class Order:
"""
Represents a customer order.
The order lifecycle:
1. Created (pending)
2. Payment processed
3. Shipped
4. Delivered (or Cancelled)
Use the `status` field to track order state.
"""
id: str
status: OrderStatus # pending, processing, shipped, delivered, cancelled
total: Decimal
line_items: list['LineItem']

Follow GraphQL conventions (camelCase in GraphQL, snake_case in Python):

@fraiseql.type
class User:
# Python: snake_case
created_at: DateTime # Maps to createdAt in GraphQL
first_name: str # Maps to firstName in GraphQL
is_active: bool # Maps to isActive in GraphQL

Validation is implemented in your PostgreSQL fn_* functions via RAISE EXCEPTION, or at the type level using custom scalars. There is no fraiseql.validate() function:

from typing import Annotated
import fraiseql
@fraiseql.input
class CreateUserInput:
email: str # Validate in fn_create_user() with RAISE EXCEPTION
age: int # Validate in fn_create_user() with RAISE EXCEPTION
# For reusable scalar validation, use a CustomScalar:
# email: Email # Where Email is a fraiseql.scalar subclassing CustomScalar

“Type not found”:

Error: Type 'Product' referenced but not defined
  • Ensure type is defined before use
  • Check for circular imports
  • Use forward references with string literals: 'Product'

“Field has no type annotation”:

Error: Field 'name' in User has no type annotation
  • All fields must have type hints
  • Use str, int, float, bool for basic types

“Cannot resolve field”:

Error: Cannot resolve field 'category' on type 'Product'
  • Check the SQL view (v_product) has the field
  • Verify foreign key relationships in database

“Type mismatch”:

Error: Expected String, got Int for field 'price'
  • Ensure Python types match database types
  • Use Decimal for monetary values, not float