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)
@fraiseql.query
async def active_users(info, limit: int = 10) -> list[User]:
"""Get recently active users."""
return await info.context.db.query(
"SELECT * FROM v_user WHERE last_seen > now() - interval '1 day' LIMIT $1",
limit
)
@fraiseql.query
async def search_posts(info, query: str) -> list[Post]:
"""Search posts by title or content."""
return await info.context.db.query(
"SELECT * FROM v_post WHERE title ILIKE $1 OR content ILIKE $1",
f"%{query}%"
)

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

@fraiseql.mutation
async def publish_post(info, id: str) -> Post:
"""Publish a draft post."""
await info.context.db.execute(
"UPDATE posts SET published = true WHERE id = $1",
id
)
return await info.context.db.find_one("v_post", id=id)
@fraiseql.mutation
async def archive_user(info, id: str) -> bool:
"""Archive a user and their content."""
async with info.context.db.transaction():
await info.context.db.execute(
"UPDATE users SET archived = true WHERE id = $1", id
)
await info.context.db.execute(
"UPDATE posts SET published = false WHERE author_id = $1", id
)
return True
@fraiseql.type
class User:
id: str
name: str
email: str = fraiseql.field(private=True) # Not in public API
password_hash: str = fraiseql.field(exclude=True) # Never exposed
OptionEffectUse Case
private=TrueField exists in the schema but requires authenticationInternal fields, PII
exclude=TrueField excluded from the GraphQL schema entirelyDB-only columns
@fraiseql.type
class User:
id: str
first_name: str
last_name: str
@fraiseql.computed
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}"
@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

Add validation at the schema level:

from typing import Annotated
@fraiseql.input
class CreateUserInput:
email: Annotated[str, fraiseql.validate(
pattern=r'^[^@]+@[^@]+\.[^@]+$',
message="Invalid email format"
)]
age: Annotated[int, fraiseql.validate(
minimum=13,
maximum=120
)]

“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