Full Tutorial
Python SDK
The FraiseQL Python SDK is a 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.
Installation
Section titled “Installation”# uv (recommended)uv add fraiseql
# pippip install fraiseqlRequirements: Python 3.10+
Core Concepts
Section titled “Core Concepts”FraiseQL Python provides four decorators that map your Python types to GraphQL schema constructs:
| Decorator | GraphQL Equivalent | Purpose |
|---|---|---|
@fraiseql.type | type | Define a GraphQL output type |
@fraiseql.input | input | Define a GraphQL input type |
@fraiseql.query | Query field | Wire a query to a SQL view |
@fraiseql.mutation | Mutation field | Define a mutation |
Defining Types
Section titled “Defining Types”Use @fraiseql.type to define GraphQL output types. Fields map 1:1 to columns 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 # Nested types are composed from views at compile time author: User comments: list['Comment']
@fraiseql.typeclass Comment: """A comment on a post.""" id: ID content: str created_at: DateTime author: UserBuilt-in Scalars
Section titled “Built-in Scalars”FraiseQL provides semantic scalars that add validation and documentation:
from fraiseql.scalars import ( ID, # UUID, auto-serialized Email, # Validated email address Slug, # URL-safe slug DateTime, # ISO 8601 datetime URL, # Validated URL PhoneNumber, # E.164 phone number)Defining Inputs
Section titled “Defining Inputs”Use @fraiseql.input to define GraphQL input types for mutations. Add validation with fraiseql.validate():
from typing import Annotatedimport fraiseqlfrom fraiseql.scalars import ID, Email
@fraiseql.inputclass CreateUserInput: username: Annotated[str, fraiseql.validate( min_length=3, max_length=50, pattern=r'^[a-z0-9_]+$', message="Username must be 3-50 lowercase alphanumeric characters or underscores" )] email: Annotated[Email, fraiseql.validate( message="Must be a valid email address" )] bio: str | None = None
@fraiseql.inputclass CreatePostInput: title: Annotated[str, fraiseql.validate(min_length=1, max_length=200)] content: str author_id: ID is_published: bool = False
@fraiseql.inputclass UpdatePostInput: id: ID title: str | None = None content: str | None = None is_published: bool | None = NoneValidation runs at compile time — invalid input structures cannot be deployed.
Defining Queries
Section titled “Defining Queries”Use @fraiseql.query to wire queries to SQL views. The sql_source argument names the view that backs this query:
import fraiseqlfrom fraiseql.scalars import ID
# List query — maps to SELECT * FROM v_post WHERE <args>@fraiseql.query(sql_source="v_post")def posts( is_published: bool | None = None, author_id: ID | None = None, limit: int = 20, offset: int = 0,) -> list[Post]: """Fetch published posts, optionally filtered by author.""" pass
# Single-item query — maps to SELECT * FROM v_post WHERE id = $1@fraiseql.query(sql_source="v_post", id_arg="id")def post(id: ID) -> Post | None: """Fetch a single post by ID.""" pass
# Query with explicit row filter@fraiseql.query( sql_source="v_post", row_filter="author_id = {current_user_id}")def my_posts(limit: int = 20) -> list[Post]: """Fetch the current user's posts.""" passHow Arguments Become WHERE Clauses
Section titled “How Arguments Become WHERE Clauses”FraiseQL’s automatic-where feature maps query arguments to SQL filters automatically. Declare an argument whose name matches a column in the backing view, and FraiseQL appends it as a WHERE clause:
query { posts(is_published: true, author_id: "usr_01HZ3K") { ... }}Becomes:
SELECT data FROM v_postWHERE is_published = true AND author_id = 'usr_01HZ3K'LIMIT 20 OFFSET 0;No resolver code required.
Defining Mutations
Section titled “Defining Mutations”Use @fraiseql.mutation to define mutations that execute PostgreSQL functions:
import fraiseqlfrom fraiseql.scalars import ID
@fraiseql.mutationdef create_user(info, input: CreateUserInput) -> User: """Create a new user account.""" # FraiseQL calls your PostgreSQL function: # SELECT * FROM fn_create_user($1::jsonb) pass
@fraiseql.mutationdef create_post(info, input: CreatePostInput) -> Post: """Create a new blog post.""" pass
@fraiseql.mutationdef publish_post(info, id: ID) -> Post: """Publish a draft post.""" pass
@fraiseql.mutationdef delete_post(info, id: ID) -> bool: """Soft-delete a post.""" passEach mutation maps to a PostgreSQL function in db/schema/03_functions/. The Python definition is the schema; the SQL function is the implementation.
Advanced Decorator Parameters
Section titled “Advanced Decorator Parameters”Subscription Filter Fields
Section titled “Subscription Filter Fields”Declare which arguments are used as server-side event filters. Only events matching the argument values are delivered to each subscriber.
@fraiseql.subscription( sql_source="tb_order", event="UPDATE", filter_fields=["user_id", "status"] # filter events server-side)def order_updated(user_id: fraiseql.ID | None = None, status: str | None = None) -> Order: passMutation Cache Invalidation
Section titled “Mutation Cache Invalidation”Declare which SQL views a mutation modifies so FraiseQL can purge cached responses automatically.
@fraiseql.mutation( fn_name="create_post", invalidates=["v_post", "v_post_list"] # purge these view caches on success)def create_post(input: PostInput) -> PostResult: passField-Level Access Control
Section titled “Field-Level Access Control”Use fraiseql.field() to restrict individual fields:
@fraiseql.typeclass User: id: fraiseql.ID name: str email: str # Only users with admin:read scope can see internal_notes; others get null internal_notes: Annotated[str | None, fraiseql.field( requires_scope="admin:read", on_deny="mask" # "mask" returns null | "error" raises an error )]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(sql_source="v_admin_log", requires_role="admin")def admin_logs(limit: int = 100) -> list[AdminLog]: """Only admin-role users can execute this query.""" passAuthorization
Section titled “Authorization”Use @fraiseql.authenticated and @requires_scope to protect queries and mutations:
from fraiseql.auth import authenticated, requires_scopeimport fraiseql
@fraiseql.query(sql_source="v_post")@authenticateddef posts(limit: int = 20) -> list[Post]: """Requires authentication.""" pass
@fraiseql.mutation@authenticated@requires_scope("write:posts")def create_post(info, input: CreatePostInput) -> Post: """Requires authentication and write:posts scope.""" pass
@fraiseql.mutation@authenticated@requires_scope("admin:posts")def delete_post(info, id: ID) -> bool: """Requires admin scope.""" passMiddleware
Section titled “Middleware”Use @fraiseql.middleware to intercept requests and set context:
import fraiseql
@fraiseql.middlewaredef extract_user_context(request, next): """Extract user ID and org from verified JWT.""" if request.auth: request.context["current_user_id"] = request.auth.claims.get("sub") request.context["current_org_id"] = request.auth.claims.get("org_id") return next(request)
@fraiseql.middlewareasync def audit_mutations(request, next): """Log all mutations for compliance.""" result = await next(request) if request.operation_type == "mutation": await audit_log.record( user_id=request.context.get("current_user_id"), operation=request.operation_name, ) return resultValues set on request.context are available in row_filter expressions and in mutation info.context.
Complete Schema Example
Section titled “Complete Schema Example”A full blog API schema:
import fraiseqlfrom fraiseql.scalars import ID, Email, Slug, DateTimefrom fraiseql.auth import authenticated, requires_scopefrom typing import Annotated
# --- Types ---
@fraiseql.typeclass User: id: ID username: str email: Email bio: str | None created_at: DateTime
@fraiseql.typeclass 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: id: ID content: str created_at: DateTime author: User
# --- Inputs ---
@fraiseql.inputclass CreatePostInput: title: Annotated[str, fraiseql.validate(min_length=1, max_length=200)] content: str is_published: bool = False
@fraiseql.inputclass CreateCommentInput: post_id: ID content: Annotated[str, fraiseql.validate(min_length=1, max_length=10_000)]
# --- Queries ---
@fraiseql.query(sql_source="v_post")def posts(is_published: bool | None = None, limit: int = 20) -> list[Post]: pass
@fraiseql.query(sql_source="v_post", id_arg="id")def post(id: ID) -> Post | None: pass
# --- Mutations ---
@fraiseql.mutation@authenticated@requires_scope("write:posts")def create_post(info, input: CreatePostInput) -> Post: pass
@fraiseql.mutation@authenticated@requires_scope("write:comments")def create_comment(info, input: CreateCommentInput) -> Comment: pass
# --- Middleware ---
@fraiseql.middlewaredef set_user_context(request, next): if request.auth: request.context["current_user_id"] = request.auth.claims.get("sub") return next(request)Build and Serve
Section titled “Build and Serve”-
Build the schema — compiles Python types to the FraiseQL IR:
Terminal window fraiseql compileExpected output:
✓ Schema compiled: 3 types, 2 queries, 2 mutations✓ Views validated against database✓ Build complete: schema.json -
Serve the API:
Terminal window fraiseql runExpected output:
✓ FraiseQL 2.0.0 running on http://localhost:8080/graphql✓ GraphQL Playground at http://localhost:8080/graphql
Testing
Section titled “Testing”FraiseQL provides a test client that compiles your schema and runs queries against a real database:
import pytestfrom fraiseql.testing import TestClient
@pytest.fixturedef client(tmp_db): return TestClient(schema_path="schema.py", database_url=tmp_db)
def test_create_and_fetch_post(client): # Create a post result = client.mutate(""" mutation { createPost(input: { title: "Hello", content: "World" }) { id title isPublished } } """) assert result["createPost"]["title"] == "Hello" assert result["createPost"]["isPublished"] == False
# Fetch it back post_id = result["createPost"]["id"] result = client.query(f""" query {{ post(id: "{post_id}") {{ title content }} }} """) assert result["post"]["title"] == "Hello"
def test_list_posts_filtered(client): result = client.query(""" query { posts(isPublished: true, limit: 10) { id title } } """) assert isinstance(result["posts"], list)Next Steps
Section titled “Next Steps”Custom Queries
Security
Other SDKs