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.
Installation
Section titled “Installation”# uv (recommended)uv add fraiseql
# pippip install fraiseqlRequirements: Python 3.10+, version 2.1.0
Core Concepts
Section titled “Core Concepts”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.
| Decorator | GraphQL Equivalent | Purpose |
|---|---|---|
@fraiseql.type | type | Define a GraphQL output type |
@fraiseql.input | input | Define a GraphQL input type |
@fraiseql.enum | enum | Define a GraphQL enum |
@fraiseql.interface | interface | Define a GraphQL interface |
@fraiseql.query | Query field | Wire a query to a SQL view |
@fraiseql.mutation | Mutation field | Wire a mutation to a SQL function |
@fraiseql.subscription | Subscription field | Define a real-time subscription |
@fraiseql.scalar | scalar | Register a custom scalar |
Defining Types
Section titled “Defining Types”Use @fraiseql.type to define GraphQL output types. Fields map 1:1 to keys in your backing SQL view’s data JSONB object.
import fraiseqlfrom fraiseql.scalars import ID, Email, Slug, DateTime
@fraiseql.typeclass User: """A registered user.""" id: ID username: str email: Email bio: str | None created_at: DateTime
@fraiseql.typeclass 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.typeclass Comment: """A comment on a post.""" id: ID content: str created_at: DateTime author: UserAuto-CRUD Generation
Section titled “Auto-CRUD Generation”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: DateTimeThis generates:
post(id: ID): Post— single lookup viav_postposts(limit, offset, ...): [Post]— list query viav_postcreate_post(input: CreatePostInput): Post— viafn_create_postupdate_post(id: ID, input: UpdatePostInput): Post— viafn_update_postdelete_post(id: ID): Post— viafn_delete_post
You can also select specific operations:
@fraiseql.type(crud=["create", "read", "list"]) # no update/deleteclass AuditLog: id: ID action: str created_at: DateTimeThe generated SQL source names follow Trinity pattern conventions: v_{snake_case} for views, fn_{action}_{snake_case} for functions.
Tenant-Scoped Types
Section titled “Tenant-Scoped Types”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: strThis 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: DateTimeBuilt-in Scalars
Section titled “Built-in Scalars”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)Defining Inputs
Section titled “Defining Inputs”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:
import fraiseqlfrom fraiseql.scalars import ID, Email
@fraiseql.inputclass CreateUserInput: """Input for creating a new user.""" username: str email: Email bio: str | None = None
@fraiseql.inputclass CreatePostInput: """Input for creating a new blog post.""" title: str content: str author_id: ID is_published: bool = False
@fraiseql.inputclass UpdatePostInput: """Input for updating an existing post.""" id: ID title: str | None = None content: str | None = None is_published: bool | None = NoneThe UNSET Sentinel
Section titled “The UNSET Sentinel”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 fraiseqlfrom fraiseql import UNSET
@fraiseql.inputclass 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| Value | Meaning | SQL behavior |
|---|---|---|
"Alice" | Set to this value | SET name = 'Alice' |
None | Set to null | SET name = NULL |
UNSET | Don’t touch this field | Field excluded from UPDATE |
This three-way distinction is essential for PATCH-style partial updates where clients only send the fields they want to change.
Defining Queries
Section titled “Defining Queries”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:
import fraiseqlfrom fraiseql.scalars import ID
# List query — maps to SELECT data FROM v_post WHERE <args>@fraiseql.querydef 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.querydef 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 filterdef my_posts(limit: int = 20) -> list[Post]: """Fetch the authenticated user's posts.""" return fraiseql.config(sql_source="v_post")How Arguments Become WHERE Clauses
Section titled “How Arguments Become WHERE Clauses”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.
Injecting JWT Claims
Section titled “Injecting JWT Claims”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>" formatdef 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.
Cache Invalidation Across Views
Section titled “Cache Invalidation Across Views”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.querydef 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.
Defining Mutations
Section titled “Defining Mutations”Use @fraiseql.mutation(sql_source=..., operation=...) to wire mutations to PostgreSQL fn_ functions. The backing SQL function must return a mutation_response composite type:
import fraiseqlfrom 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.""" passEach 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.
Advanced Decorator Parameters
Section titled “Advanced Decorator Parameters”Cache Invalidation
Section titled “Cache Invalidation”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: passSubscriptions
Section titled “Subscriptions”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.""" passArguments become server-side event filters: only events matching the argument values are delivered to each subscriber.
Field-Level Access Control
Section titled “Field-Level Access Control”Use fraiseql.field() with Annotated to restrict individual fields by JWT scope:
from typing import Annotatedimport fraiseql
@fraiseql.typeclass 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).
Type-Level Role Restriction
Section titled “Type-Level Role Restriction”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: intQuery-Level Role Restriction
Section titled “Query-Level Role Restriction”@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")Custom Scalars
Section titled “Custom Scalars”Use @fraiseql.scalar to register custom scalar types. Subclass CustomScalar and implement three methods:
from fraiseql import CustomScalar, scalar
@scalarclass 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.typeclass SlackIntegration: id: ID slack_user_id: SlackUserId workspace: strEnums and Interfaces
Section titled “Enums and Interfaces”from enum import Enumimport fraiseql
@fraiseql.enumclass PostStatus(Enum): """Publication status of a post.""" DRAFT = "draft" PUBLISHED = "published" ARCHIVED = "archived"
@fraiseql.typeclass Post: id: ID title: str status: PostStatusInterfaces
Section titled “Interfaces”@fraiseql.interfaceclass Node: """An object with a globally unique ID.""" id: ID
@fraiseql.type(implements=["Node"])class User: id: ID username: str email: EmailComplete example
Section titled “Complete example”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
Section titled “Transport Annotations”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 fraiseqlfrom uuid import UUID
@fraiseql.typeclass Post: id: UUID title: str author_id: UUID
# GraphQL only@fraiseql.querydef 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 namedef 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.
Build and Serve
Section titled “Build and Serve”-
Export the schema from your Python definitions:
Terminal window python schema.pyThis writes
schema.jsoncontaining the compiled type registry. -
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.jsonThe
--databaseflag enables three-level validation — checking thatsql_sourceviews exist, columns match, and JSONB keys are present. Omit it to compile without database access. -
Serve the API:
Terminal window fraiseql runExpected output:
✓ FraiseQL 2.1.0 running on http://localhost:8080/graphql
Next Steps
Section titled “Next Steps”- SDK Overview — how compile-time authoring works
- SQL Patterns — view and function conventions
- FraiseQL for Python Teams — decorator patterns, REST annotations, SpecQL
- Your First API — full tutorial
- All SDKs — compare languages