Skip to content

Python SDK

The FraiseQL Python SDK is a compile-time schema authoring SDK: you define GraphQL types, queries, and mutations in Python, and the FraiseQL compiler generates an optimized GraphQL API backed by your SQL views. There is no runtime FFI — decorators output JSON only. Function bodies are never executed.

Terminal window
# uv (recommended)
uv add fraiseql
# pip
pip install fraiseql

Requirements: Python 3.10+, version 2.1.0

FraiseQL Python provides decorators that map your Python types to GraphQL schema constructs. All decorators register metadata in a schema registry and return the original class or function unmodified. None of them add runtime behavior.

DecoratorGraphQL EquivalentPurpose
@fraiseql.typetypeDefine a GraphQL output type
@fraiseql.inputinputDefine a GraphQL input type
@fraiseql.enumenumDefine a GraphQL enum
@fraiseql.interfaceinterfaceDefine a GraphQL interface
@fraiseql.queryQuery fieldWire a query to a SQL view
@fraiseql.mutationMutation fieldWire a mutation to a SQL function
@fraiseql.subscriptionSubscription fieldDefine a real-time subscription
@fraiseql.scalarscalarRegister a custom scalar

Use @fraiseql.type to define GraphQL output types. Fields map 1:1 to keys in your backing SQL view’s data JSONB object.

schema.py
import fraiseql
from fraiseql.scalars import ID, Email, Slug, DateTime
@fraiseql.type
class User:
"""A registered user."""
id: ID
username: str
email: Email
bio: str | None
created_at: DateTime
@fraiseql.type
class Post:
"""A blog post."""
id: ID
title: str
slug: Slug
content: str
is_published: bool
created_at: DateTime
updated_at: DateTime
author: User
comments: list['Comment']
@fraiseql.type
class Comment:
"""A comment on a post."""
id: ID
content: str
created_at: DateTime
author: User

Use crud=True on @fraiseql.type to auto-generate standard CRUD queries and mutations without writing them manually:

@fraiseql.type(crud=True)
class Post:
"""A blog post — CRUD operations generated automatically."""
id: ID
title: str
content: str
is_published: bool
created_at: DateTime

This generates:

  • post(id: ID): Post — single lookup via v_post
  • posts(limit, offset, ...): [Post] — list query via v_post
  • create_post(input: CreatePostInput): Post — via fn_create_post
  • update_post(id: ID, input: UpdatePostInput): Post — via fn_update_post
  • delete_post(id: ID): Post — via fn_delete_post

You can also select specific operations:

@fraiseql.type(crud=["create", "read", "list"]) # no update/delete
class AuditLog:
id: ID
action: str
created_at: DateTime

The generated SQL source names follow Trinity pattern conventions: v_{snake_case} for views, fn_{action}_{snake_case} for functions.

Use tenant_scoped=True to automatically inject tenant_id from the JWT on all queries and mutations for this type:

@fraiseql.type(tenant_scoped=True)
class Invoice:
"""Tenant-scoped invoice — tenant_id injected automatically."""
id: ID
amount: Decimal
status: str

This is equivalent to adding inject={"tenant_id": "jwt:tenant_id"} to every query and mutation that targets this type. Combine with crud=True for zero-boilerplate multi-tenant schemas:

@fraiseql.type(crud=True, tenant_scoped=True)
class Project:
id: ID
name: str
created_at: DateTime

FraiseQL provides semantic scalar types that generate correct GraphQL scalar declarations and are validated by the Rust runtime:

from fraiseql.scalars import (
ID, # UUID v4 — use for all `id` fields and foreign key references
Email, # RFC 5322 validated email address
Slug, # URL-safe slug (lowercase, hyphens, no spaces)
DateTime, # ISO 8601 datetime (e.g., "2025-01-10T12:00:00Z")
Date, # ISO 8601 date (e.g., "2025-01-10")
URL, # RFC 3986 validated URL
PhoneNumber, # E.164 phone number
Json, # Arbitrary JSON — maps to PostgreSQL JSONB
Decimal, # Precise decimal for financial values
)

Use @fraiseql.input to define GraphQL input types for mutations. Input validation is enforced by SQL constraints and the FraiseQL ELO (Execution Layer Optimizer) — not by Python runtime checks. Declare fields using standard Python type annotations:

schema.py
import fraiseql
from fraiseql.scalars import ID, Email
@fraiseql.input
class CreateUserInput:
"""Input for creating a new user."""
username: str
email: Email
bio: str | None = None
@fraiseql.input
class CreatePostInput:
"""Input for creating a new blog post."""
title: str
content: str
author_id: ID
is_published: bool = False
@fraiseql.input
class UpdatePostInput:
"""Input for updating an existing post."""
id: ID
title: str | None = None
content: str | None = None
is_published: bool | None = None

For update mutations, None means “set this field to null” — but what if you want to say “don’t update this field at all”? Use fraiseql.UNSET:

import fraiseql
from fraiseql import UNSET
@fraiseql.input
class UpdateUserInput:
"""Partial update — UNSET fields are excluded from the SQL UPDATE."""
name: str | None = UNSET # omitted from UPDATE unless explicitly provided
email: str | None = UNSET # omitted from UPDATE unless explicitly provided
bio: str | None = UNSET # omitted — or set to None to clear it
ValueMeaningSQL behavior
"Alice"Set to this valueSET name = 'Alice'
NoneSet to nullSET name = NULL
UNSETDon’t touch this fieldField excluded from UPDATE

This three-way distinction is essential for PATCH-style partial updates where clients only send the fields they want to change.


Use @fraiseql.query with fraiseql.config(sql_source=...) in the function body to wire queries to SQL views. The sql_source argument names the v_ view that backs the query:

schema.py
import fraiseql
from fraiseql.scalars import ID
# List query — maps to SELECT data FROM v_post WHERE <args>
@fraiseql.query
def posts(
is_published: bool | None = None,
author_id: ID | None = None,
limit: int = 20,
offset: int = 0,
) -> list[Post]:
"""Fetch posts, optionally filtered by published status or author."""
return fraiseql.config(sql_source="v_post")
# Single-item query — nullable return type signals a possible empty result
@fraiseql.query
def post(id: ID) -> Post | None:
"""Fetch a single post by ID."""
return fraiseql.config(sql_source="v_post")
# User-scoped query using JWT claim injection
@fraiseql.query(inject={"author_id": "jwt:sub"}) # inject JWT `sub` claim as author_id filter
def my_posts(limit: int = 20) -> list[Post]:
"""Fetch the authenticated user's posts."""
return fraiseql.config(sql_source="v_post")

Query arguments whose names match columns in the backing view become SQL WHERE clauses automatically — no resolver code needed. See SQL Patterns → Automatic WHERE Clauses for details.

Use inject= to pull values from the JWT into SQL parameters without exposing them as GraphQL arguments:

@fraiseql.query(inject={"org_id": "jwt:org_id"}) # "jwt:<claim_name>" format
def org_posts(limit: int = 20) -> list[Post]:
"""Return posts belonging to the caller's organization."""
return fraiseql.config(sql_source="v_post")

The org_id parameter is injected from the JWT claim org_id and never appears in the GraphQL schema.

Use additional_views= when a query reads from multiple SQL views. FraiseQL uses this list to evict the query’s cache entries when any of those views change:

@fraiseql.query
def posts_with_authors(limit: int = 20) -> list[Post]:
"""Fetch posts including denormalized author and tag data."""
return fraiseql.config(
sql_source="v_post",
additional_views=["v_user", "v_tag"], # also read from these views
)

Without additional_views, only mutations targeting v_post would evict this query’s cache. With it, mutations to v_user or v_tag also trigger eviction.


Use @fraiseql.mutation(sql_source=..., operation=...) to wire mutations to PostgreSQL fn_ functions. The backing SQL function must return a mutation_response composite type:

schema.py
import fraiseql
from fraiseql.scalars import ID
@fraiseql.mutation(sql_source="fn_create_user", operation="CREATE")
def create_user(username: str, email: str) -> User:
"""Create a new user account."""
pass
@fraiseql.mutation(sql_source="fn_create_post", operation="CREATE")
def create_post(input: CreatePostInput) -> Post:
"""Create a new blog post."""
pass
@fraiseql.mutation(sql_source="fn_publish_post", operation="UPDATE")
def publish_post(id: ID) -> Post:
"""Publish a draft post."""
pass
@fraiseql.mutation(sql_source="fn_delete_post", operation="DELETE")
def delete_post(id: ID) -> Post:
"""Soft-delete a post."""
pass

Each mutation maps to a PostgreSQL function following the fn_{action}_{entity} naming convention. The Python definition is the schema declaration; the SQL function is the implementation. See SQL Patterns → Mutation Functions for the mutation_response type and function templates.


Declare which SQL views a mutation modifies so FraiseQL can purge cached responses automatically:

@fraiseql.mutation(
sql_source="fn_create_post",
operation="CREATE",
invalidates_views=["v_post", "v_post_list"], # purge these view caches on success
)
def create_post(input: CreatePostInput) -> Post:
pass

Use @fraiseql.subscription to declare real-time subscriptions. Subscriptions in FraiseQL are compiled projections of database events sourced from LISTEN/NOTIFY — not resolver-based:

from fraiseql.scalars import ID
@fraiseql.subscription(
entity_type="Order",
operation="UPDATE",
topic="order_status_changed",
)
def order_updated(user_id: ID | None = None, status: str | None = None) -> Order:
"""Subscribe to order status changes."""
pass

Arguments become server-side event filters: only events matching the argument values are delivered to each subscriber.

Use fraiseql.field() with Annotated to restrict individual fields by JWT scope:

from typing import Annotated
import fraiseql
@fraiseql.type
class User:
id: ID
name: str
email: str
# Requires "hr:view_pii" scope — query fails with FORBIDDEN if absent
salary: Annotated[int, fraiseql.field(requires_scope="hr:view_pii")]
# Requires scope — returns null instead of failing if absent
internal_notes: Annotated[str | None, fraiseql.field(
requires_scope="admin:read",
on_deny="mask", # "mask" returns null | "reject" raises FORBIDDEN
)]

Valid values for on_deny are "reject" (default — query fails with a FORBIDDEN error) and "mask" (query succeeds, field returns null).

Restrict an entire type to users with a specific role:

@fraiseql.type(requires_role="admin")
class AdminDashboard:
"""Only visible to users with the admin role."""
total_users: int
revenue_today: float
pending_reports: int
@fraiseql.query(requires_role="admin")
def admin_logs(limit: int = 100) -> list[AdminLog]:
"""Only admin-role users can execute this query."""
return fraiseql.config(sql_source="v_admin_log")

Use @fraiseql.scalar to register custom scalar types. Subclass CustomScalar and implement three methods:

from fraiseql import CustomScalar, scalar
@scalar
class SlackUserId(CustomScalar):
"""Slack user ID in the format U01234ABCDE."""
name = "SlackUserId"
def serialize(self, value: str) -> str:
return str(value)
def parse_value(self, value: str) -> str:
s = str(value)
if not s.startswith("U") or len(s) != 11:
raise ValueError(f"Invalid Slack user ID: {s!r}")
return s
def parse_literal(self, ast) -> str:
if hasattr(ast, "value"):
return self.parse_value(ast.value)
raise ValueError("Invalid Slack user ID literal")
# Use in a type definition
@fraiseql.type
class SlackIntegration:
id: ID
slack_user_id: SlackUserId
workspace: str

from enum import Enum
import fraiseql
@fraiseql.enum
class PostStatus(Enum):
"""Publication status of a post."""
DRAFT = "draft"
PUBLISHED = "published"
ARCHIVED = "archived"
@fraiseql.type
class Post:
id: ID
title: str
status: PostStatus
@fraiseql.interface
class Node:
"""An object with a globally unique ID."""
id: ID
@fraiseql.type(implements=["Node"])
class User:
id: ID
username: str
email: Email

For a complete blog API schema combining all the patterns above, see the fraiseql-starter-blog repository. To export the compiled schema:

fraiseql.export_schema("schema.json")

Transport annotations are optional. Omit them to serve an operation via GraphQL only. Add rest_path/rest_method to also expose it as a REST endpoint. gRPC endpoints are auto-generated when [grpc] is enabled — no per-operation annotation needed. See gRPC Transport.

import fraiseql
from uuid import UUID
@fraiseql.type
class Post:
id: UUID
title: str
author_id: UUID
# GraphQL only
@fraiseql.query
def posts_graphql(limit: int = 10) -> list[Post]:
return fraiseql.config(sql_source="v_post")
# REST + GraphQL
@fraiseql.query(rest_path="/posts", rest_method="GET")
def posts(limit: int = 10) -> list[Post]:
return fraiseql.config(sql_source="v_post")
# REST with path parameter
@fraiseql.query(rest_path="/posts/{id}", rest_method="GET") # {id} must match function arg name
def post(id: UUID) -> Post:
return fraiseql.config(sql_source="v_post")
# REST with custom path parameter — just match the placeholder name to the function arg
@fraiseql.query(rest_path="/posts/{slug}", rest_method="GET")
def post_by_slug(slug: str) -> Post:
return fraiseql.config(sql_source="v_post")
# REST mutation
@fraiseql.mutation(
sql_source="create_post",
operation="CREATE",
rest_path="/posts",
rest_method="POST",
)
def create_post(title: str, author_id: UUID) -> Post: ...

Path parameters in rest_path (e.g., {id}) must match function argument names exactly. A mismatch produces a compile-time error. Duplicate (method, path) pairs are also rejected at compile time.


  1. Export the schema from your Python definitions:

    Terminal window
    python schema.py

    This writes schema.json containing the compiled type registry.

  2. Compile (and optionally validate against the database):

    Terminal window
    fraiseql compile fraiseql.toml --database "$DATABASE_URL"

    Expected output:

    ✓ Schema compiled: 3 types, 3 queries, 3 mutations
    ✓ Database validation passed: all relations, columns, and JSON keys verified.
    ✓ Build complete: schema.compiled.json

    The --database flag enables three-level validation — checking that sql_source views exist, columns match, and JSONB keys are present. Omit it to compile without database access.

  3. Serve the API:

    Terminal window
    fraiseql run

    Expected output:

    ✓ FraiseQL 2.1.0 running on http://localhost:8080/graphql