Skip to content

Decorators

FraiseQL uses decorators (Python) and decorator functions (TypeScript) to define GraphQL schema elements. This reference covers all available decorators, the GraphQL SDL they generate, and how to handle errors within decorated functions.

When a request arrives, FraiseQL processes it through a fixed pipeline:

Request
→ Middleware (in registration order)
→ Authentication (JWT/API key verification)
→ Authorization (role + scope checks)
→ Resolver (SQL view or function execution)
→ Response

When multiple decorators protect the same resolver, they execute in this order:

  1. @authenticated — JWT validation (fails fast if not logged in)
  2. @requires_scope — OAuth scope check
  3. @role — Role-based access check
  4. Resolver executes
  5. @after_mutation or @observer — Post-execution side effects

Note: @before_mutation exceptions abort the mutation and roll back the database transaction.

This means:

  • @middleware functions run before any authentication check — they can read or modify the raw request
  • Authentication failures short-circuit before reaching authorization or resolvers
  • @query/@mutation requires_scope checks run after the token is verified
  • @before_mutation hooks run inside the resolver phase, after all auth checks pass
  • @after_mutation hooks run after the SQL function completes successfully

Defines a GraphQL object type.

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

Generated GraphQL SDL:

type User {
"""A user in the system."""
id: ID!
name: String!
email: String!
createdAt: DateTime!
}
OptionTypeDescription
implementslist[str]Interfaces to implement
relayboolImplement the Node interface and enable keyset cursor generation
requires_rolestrRequired role to access this type
crudbool or list[str]Auto-generate CRUD queries/mutations. True = all ops, or list: ["create", "read", "list", "update", "delete"]
tenant_scopedboolAuto-inject tenant_id from JWT on all queries/mutations for this type
sql_sourcestrOverride the default view name (default: v_{snake_case_name})
key_fieldslist[str]Federation key fields for entity resolution. When export_schema(federation=...) is used, types without explicit key_fields default to ["id"]
extendsboolMarks this type as extending a type defined in another subgraph (Apollo Federation extend type)

When relay=True, the type implements the Node interface, the global node(id: ID!) query is added to the schema, and fraiseql compile validates that pk_{entity} is present in the corresponding SQL view.

@fraiseql.type(implements=["Node"])
class UserAnalytics:
id: ID
page_views: int

Use Annotated (Python) or field decorators (TypeScript) for field-level options:

from typing import Annotated
@fraiseql.type
class User:
id: ID
email: str
# Protected field — hidden or rejected if scope missing
salary: Annotated[Decimal, fraiseql.field(requires_scope="hr:read", on_deny="mask")]
# Deprecated field
username: Annotated[str, fraiseql.field(deprecated="Use email instead")]
# Field with description
bio: Annotated[str | None, fraiseql.field(description="User biography")]

Defines a GraphQL query.

@fraiseql.query
def user(id: ID) -> User | None:
"""Get a user by ID."""
return fraiseql.config(sql_source="v_user")

Generated GraphQL SDL:

type Query {
user(id: ID!): User
}
OptionTypeDefaultDescription
sql_sourcestrView or table to query
relayboolFalseEnable Relay spec (Connection/Edge/PageInfo, keyset cursors)
auto_paramsbool | dictinferredTrue = all four params; False = none; dict for partial overrides (e.g. {"limit": True, "offset": False}). Partial dicts merge with [query_defaults] in fraiseql.toml.
injectdict[str, str]Server-side JWT claim injection. Keys are SQL param names; values are "jwt:<claim>". See Server-Side Injection.
cache_ttl_secondsintCache duration in seconds. Set to 0 to disable caching for this query
additional_viewslist[str]NoneAdditional SQL views this query reads from. Used for cache invalidation — mutations to these views will also evict this query’s cache entries.
requires_rolestrRequired role
requires_scopestrRequired scope
rest_pathstr | NoneNoneREST endpoint path override (e.g. "/users/{id}"). When absent, REST auto-derives endpoints from query names. Path parameter names must match function argument names.
rest_methodstr | None"GET"HTTP method override for the REST endpoint. Defaults to "GET" for queries.

Transport notes:

  • REST endpoints are auto-derived for all queries/mutations when [rest] is enabled. Use rest_path/rest_method only to override the default path or method.
  • gRPC endpoints are auto-generated for all queries/mutations when [grpc] is enabled — no per-operation annotation needed. Filter with include_types/exclude_types in [grpc].
  • Path parameters in rest_path must match function argument names — mismatches are caught at compile time.
  • Duplicate (method, path) pairs are rejected at compile time.
# Custom REST path (overrides auto-derived /users/{id})
@fraiseql.query(rest_path="/api/users/{id}", rest_method="GET")
def get_user(id: UUID) -> User:
return fraiseql.config(sql_source="v_user")
@fraiseql.mutation(
sql_source="create_user",
operation="CREATE",
rest_path="/api/users",
rest_method="POST",
)
def create_user(email: str, name: str) -> User: ...

For list queries (-> list[T]), all four params are enabled automatically. Pass auto_params=False to opt out:

ParamGraphQL argumentSQL
wherewhere: {Type}WhereInputWHERE …
orderByorderBy: [{Type}OrderByInput!]ORDER BY …
limitlimit: IntLIMIT …
offsetoffset: IntOFFSET …
# All four auto-enabled (list return type)
@fraiseql.query
def posts() -> list[Post]:
return fraiseql.config(sql_source="v_post")
# Opt out entirely
@fraiseql.query(auto_params=False)
def posts() -> list[Post]:
return fraiseql.config(sql_source="v_post")

When relay=True, the query generates a Relay-spec {Type}Connection instead of [Type!]!, and limit/offset are replaced by first/after/last/before. Requires @fraiseql.type(relay=True) on the return type and pk_{entity} in the SQL view.

@fraiseql.type(relay=True)
class Post:
id: ID
title: str
@fraiseql.query
def posts() -> list[Post]:
return fraiseql.config(sql_source="v_post", relay=True)

See Relay Pagination for full details.

Defines a GraphQL mutation.

@fraiseql.mutation(sql_source="fn_create_user", operation="CREATE")
def create_user(email: str, name: str) -> User:
"""Create a new user."""
pass

Generated GraphQL SDL:

type Mutation {
createUser(email: String!, name: String!): User!
}
OptionTypeDefaultDescription
sql_sourcestrSQL function to call
operationstr"CREATE", "UPDATE", "DELETE", "CUSTOM"
injectdict[str, str]Server-side JWT claim injection. Keys are SQL param names; values are "jwt:<claim>". Injected params are never exposed to clients. See Server-Side Injection.
requires_rolestrRequired role
requires_scopestrRequired scope
invalidates_viewslist[str]View names to purge from response cache after the mutation runs (e.g. ["v_post", "v_comment"]).
invalidates_fact_tableslist[str]Fact table names to invalidate after the mutation (e.g. ["tf_sales"]).
cascadeboolNoneExpose GraphQL Cascade data in the mutation response. When True, the cascade field from mutation_response is included in the GraphQL response and fed into the cache invalidation pipeline. When None (default), falls back to the [cascade].enabled TOML setting.
rest_pathstr | NoneNoneREST endpoint path override (e.g. "/users"). When absent, REST auto-derives endpoints from mutation names.
rest_methodstr | None"POST"HTTP method override for the REST endpoint. Defaults to "POST" for mutations.
@fraiseql.mutation(
sql_source="fn_delete_user",
operation="DELETE",
requires_scope="admin:delete_user"
)
def delete_user(id: ID) -> bool:
"""Delete a user. Requires admin scope."""
pass

Raise errors from within @before_mutation hooks or @after_mutation hooks to abort or signal failures:

# Raise errors from PostgreSQL functions using RAISE EXCEPTION —
# not from Python hooks. The Python layer is schema authoring only.
#
# In your PostgreSQL function:
# IF user_is_system THEN
# RAISE EXCEPTION 'Cannot delete system users'
# USING ERRCODE = 'P0001', HINT = 'FORBIDDEN';
# END IF;

FraiseQLError produces a structured GraphQL error with an extensions.code field:

{
"errors": [
{
"message": "Cannot delete system users",
"extensions": { "code": "FORBIDDEN" }
}
]
}

Defines a GraphQL subscription.

@fraiseql.subscription(entity_type="User", topic="user_created")
def user_created() -> User:
"""Subscribe to new users."""
pass

Generated GraphQL SDL:

type Subscription {
userCreated: User!
}
OptionTypeDescription
entity_typestrEntity to watch
topicstrNATS topic
operationstr"INSERT", "UPDATE", "DELETE"
@fraiseql.subscription(
entity_type="Order",
operation="UPDATE",
topic="order_updated"
)
def order_shipped(customer_id: ID | None = None) -> Order:
"""Subscribe to order updates."""
pass

Defines a GraphQL input type.

@fraiseql.input
class CreateUserInput:
"""Input for creating a user."""
email: str
name: str
bio: str | None = None
role: str = "user"

Generated GraphQL SDL:

input CreateUserInput {
"""Input for creating a user."""
email: String!
name: String!
bio: String
role: String!
}

Input validation is enforced in PostgreSQL functions using RAISE EXCEPTION, not via Python-level decorators. See Validation Rules for the TOML-based validation configuration that FraiseQL applies before SQL execution.

Defines a GraphQL enum.

from enum import Enum
@fraiseql.enum
class OrderStatus(Enum):
"""Status of an order."""
PENDING = "pending"
CONFIRMED = "confirmed"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"

Usage:

@fraiseql.type
class Order:
id: ID
status: OrderStatus

Defines a GraphQL interface.

@fraiseql.interface
class Node:
"""An object with an ID."""
id: ID
@fraiseql.interface
class Timestamped:
"""An object with timestamps."""
created_at: DateTime
updated_at: DateTime

Implement interfaces:

@fraiseql.type(implements=["Node", "Timestamped"])
class User:
id: ID
name: str
created_at: DateTime
updated_at: DateTime

Defines a GraphQL union type.

@fraiseql.type
class User:
id: ID
name: str
@fraiseql.type
class Post:
id: ID
title: str
@fraiseql.union(members=[User, Post])
class SearchResult:
"""Result from a search query."""
pass

Usage:

@fraiseql.query
def search(query: str) -> list[SearchResult]:
return fraiseql.config(sql_source="fn_search")

Defines a custom scalar type. The class must inherit from CustomScalar and implement serialize, parse_value, and parse_literal.

from fraiseql import CustomScalar, scalar
@scalar
class TaxId(CustomScalar):
"""Tax ID with validation."""
name = "TaxId"
def serialize(self, value: str) -> str:
return value.upper().replace("-", "")
def parse_value(self, value: str) -> str:
cleaned = value.upper().replace("-", "")
if len(cleaned) != 9:
raise ValueError("Tax ID must be 9 characters")
return cleaned
def parse_literal(self, ast) -> str:
return self.parse_value(ast.value)

Use @fraiseql.error to annotate error types returned by mutations. Scalar fields (str, int, float, datetime, UUID, etc.) are populated from the metadata JSONB column of the mutation_response row returned by your SQL function. Both camelCase and snake_case metadata keys are resolved automatically.

@fraiseql.error
class InsufficientFundsError:
code: str
required_amount: float
available_amount: float
@fraiseql.mutation(sql_source="fn_transfer_funds")
def transfer_funds(
from_id: str,
to_id: str,
amount: float
) -> Transfer | InsufficientFundsError:
...

This generates a GraphQL union return type — clients handle domain errors as typed data rather than error extensions. The SQL function must return a mutation_response row; when status begins with "failed:" or is "error", the metadata JSONB is used to populate the error type’s fields.

Field-level configuration for use with Annotated.

fraiseql.field(
requires_scope="scope", # Required OAuth scope
on_deny="reject", # "reject" (default) or "mask"
deprecated="message", # Deprecation reason
description="text", # Field description
)
ParameterTypeDescription
requires_scopestrOAuth scope required to read this field
on_denystr"reject" raises an error; "mask" returns null
deprecatedstrMarks the field deprecated with a reason message
descriptionstrField description exposed in introspection
@fraiseql.type
class User:
bio: str | None # Nullable field
@fraiseql.type
class User:
roles: list[str] # Non-null list, non-null items
tags: list[str] | None # Nullable list
@fraiseql.type
class User:
posts: list['Post'] # Forward reference
manager: 'User | None' # Self-reference
DecoratorPurposeKey Options
@typeDefine object typeimplements, relay, requires_role
@inputDefine input type
@queryDefine querysql_source, auto_params, cache_ttl_seconds, rest_path
@mutationDefine mutationsql_source, operation, requires_scope, cascade, rest_path
@subscriptionDefine subscriptionentity_type, topic, operation
@enumDefine enum
@interfaceDefine interface
@unionDefine unionmembers
@scalarDefine custom scalar
@errorDefine error type
@fraiseql.query(auto_params={"limit": True, "offset": True, "where": True, "order_by": True})
def users() -> list[User]:
return fraiseql.config(sql_source="v_user")
# With Annotated
from typing import Annotated
@fraiseql.type
class User:
id: ID
email: Annotated[str, fraiseql.field(
requires_scope="user:read_email", # Require scope
on_deny="mask", # Return null instead of error
deprecated="Use contactEmail instead", # Deprecation
description="Primary contact email", # Description
)]
  1. Define a simple type with decorators:

    import fraiseql
    @fraiseql.type
    class Product:
    id: str
    name: str
    price: float
  2. Add a query decorator:

    @fraiseql.query
    def product(id: str) -> Product:
    return fraiseql.config(sql_source="v_product")
  3. Compile the schema:

    Terminal window
    fraiseql compile

    Expected output:

    ✓ Schema validated (1 types, 1 queries)
    ✓ Compiled to schema.compiled.json
  4. Introspect the generated schema:

    Terminal window
    curl -X POST http://localhost:8080/graphql \
    -H "Content-Type: application/json" \
    -d '{"query": "{ __type(name: \"Product\") { fields { name } } }"}' | jq

    Expected response:

    {
    "data": {
    "__type": {
    "fields": [
    {"name": "id"},
    {"name": "name"},
    {"name": "price"}
    ]
    }
    }
    }
  5. Test the query:

    Terminal window
    curl -X POST http://localhost:8080/graphql \
    -H "Content-Type: application/json" \
    -d '{"query": "{ product(id: \"123\") { id name price } }"}'
  6. Test with authorization:

    @fraiseql.mutation(
    sql_source="fn_create_product",
    requires_scope="admin:products"
    )
    def create_product(name: str, price: float) -> Product:
    pass

    Test without scope (should fail):

    Terminal window
    curl -X POST http://localhost:8080/graphql \
    -H "Authorization: Bearer $USER_TOKEN" \
    -d '{"query": "mutation { createProduct(name: \"Widget\", price: 9.99) { id } }"}'

    Expected error:

    {
    "errors": [{
    "message": "Forbidden: missing scope admin:products"
    }]
    }
@fraiseql.type
class User:
pass
# Error: module 'fraiseql' has no attribute 'type'

Solution: Use correct import:

import fraiseql
@fraiseql.type # Correct
class User:
pass
Terminal window
$ fraiseql compile
Warning: Type 'User' has no fields

Check:

  1. All fields have type annotations
  2. No syntax errors in class definition
  3. Class is properly indented under decorator
@fraiseql.type
class User:
posts: list[Post] # Error: Post not defined

Solution: Use string literal for forward reference:

@fraiseql.type
class User:
posts: list['Post'] # Forward reference as string
@fraiseql.mutation(sql_source="fn_create_user")
def create_user(name: str) -> User:
pass

Check:

  1. SQL function exists: SELECT * FROM pg_proc WHERE proname = 'fn_create_user'
  2. Function returns correct type
  3. Input parameters match function signature

Scalars

Scalars — Built-in scalars