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
databasestrDatabase for federated types
dataplanestrData plane ("graphql" or "arrow")
implementslist[str]Interfaces to implement
relayboolImplement the Node interface and enable keyset cursor generation

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(database="analytics", 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
salary: Annotated[Decimal, fraiseql.field(requires_scope="hr:read")]
# Deprecated field
username: Annotated[str, fraiseql.field(deprecated="Use email instead")]
# Non-nullable with default
role: Annotated[str, fraiseql.field(default="user")]
# Computed field (not from database)
display_name: Annotated[str, fraiseql.field(computed=True)]

Defines a GraphQL query.

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

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_ttlintCache duration in seconds
cacheboolEnable/disable caching
requires_rolestrRequired role
requires_scopestrRequired scope

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(sql_source="v_post")
def posts() -> list[Post]:
pass
# Opt out entirely
@fraiseql.query(sql_source="v_post", auto_params=False)
def posts() -> list[Post]:
pass

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: UUID
title: str
@fraiseql.query(sql_source="v_post", relay=True)
def posts() -> list[Post]:
pass

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!
}
OptionTypeDescription
sql_sourcestrSQL function to call
operationstr"CREATE", "UPDATE", "DELETE"
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
cache_updateboolUpdate cache after mutation
@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"
filterstrFilter expression
jetstreamboolUse JetStream
replayboolAllow replay
@fraiseql.subscription(
entity_type="Order",
operation="UPDATE",
filter="status == 'shipped'"
)
def order_shipped(customer_id: ID | None = None) -> Order:
"""Subscribe to shipped orders."""
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!
}
from typing import Annotated
@fraiseql.input
class CreateUserInput:
email: Annotated[str, fraiseql.validate(
pattern=r"^[^@]+@[^@]+\.[^@]+$",
message="Invalid email format"
)]
name: Annotated[str, fraiseql.validate(
min_length=2,
max_length=100
)]
age: Annotated[int, fraiseql.validate(
minimum=0,
maximum=150
)]

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(sql_source="fn_search")
def search(query: str) -> list[SearchResult]:
pass

Defines a custom scalar type.

@fraiseql.scalar
class Email:
"""Email address with validation."""
@staticmethod
def serialize(value: str) -> str:
return value.lower()
@staticmethod
def parse(value: str) -> str:
if "@" not in value:
raise ValueError("Invalid email format")
return value.lower()
@staticmethod
def parse_literal(ast) -> str:
if isinstance(ast, StringValueNode):
return Email.parse(ast.value)
raise ValueError("Email must be a string")

Defines an event observer.

from fraiseql import observer, webhook, email, slack
@observer(
entity="Order",
event="INSERT",
condition="total > 1000",
actions=[
webhook("https://api.example.com/orders"),
slack("#sales", "New order: {id}")
]
)
def on_high_value_order():
"""Triggered for high-value orders."""
pass
OptionTypeDescription
entitystrEntity to watch
eventstr"INSERT", "UPDATE", "DELETE"
conditionstrTrigger condition
actionslistActions to execute
retryRetryConfigRetry configuration
# Webhook
webhook(
url="https://api.example.com",
headers={"Authorization": "Bearer {TOKEN}"},
body_template='{"id": "{{id}}"}'
)
# Email
email(
to="{customer_email}",
subject="Order {id} confirmed",
body="Your order has been confirmed."
)
# Slack
slack(
channel="#orders",
message="New order: {id} for ${total}"
)

Defines request middleware.

@fraiseql.middleware
async def log_requests(request, next):
"""Log all requests."""
start = time.time()
response = await next(request)
duration = time.time() - start
logger.info(f"{request.operation} completed in {duration:.3f}s")
return response

Hook that runs after a mutation.

@fraiseql.after_mutation("create_user")
async def after_create_user(user: User, context):
"""Send welcome email after user creation."""
await send_welcome_email(user.email, user.name)

Hook that runs before a mutation.

@fraiseql.before_mutation("delete_user")
async def before_delete_user(id: ID, context):
"""Validate deletion is allowed."""
user = await get_user(id)
if user.is_system_user:
raise ValueError("Cannot delete system user")
# Available in v2.0.0
@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 would generate a GraphQL union return type instead of raising a GraphQL error — allowing clients to handle domain errors as typed data rather than error extensions. Track progress in the FraiseQL Python SDK repository.

@observer and @after_mutation: Choosing the Right Hook

Section titled “@observer and @after_mutation: Choosing the Right Hook”

Both @observer and @after_mutation can react to data changes. They serve different purposes:

@after_mutation runs synchronously in the same transaction (blocks the response until it completes). @observer runs asynchronously after commit (fire-and-forget via NATS — does not block the response).

@after_mutation@observer
TriggerSpecific named mutationAny insert/update/delete on an entity
RunsIn-process, synchronously after mutationAsynchronously via NATS event
Use forTightly-coupled side effects (audit log, cache bust)Loosely-coupled notifications (email, webhook, Slack)
Access to contextFull request context (user, headers)Entity data only
RetriesNo built-in retryConfigurable retry with backoff

Use @after_mutation when you need the full request context and want the side effect to be part of the same request lifecycle:

@fraiseql.after_mutation("create_user")
async def after_create_user(user: User, context):
"""Bust cache for user lists immediately after creation."""
await context.cache.invalidate("users:*")

Use @observer when you need reliable delivery, retries, or want to decouple the side effect from the mutation:

@observer(
entity="User",
event="INSERT",
actions=[
email(to="{email}", subject="Welcome!", body="Hi {name}"),
],
retry=RetryConfig(max_attempts=3, backoff_strategy="exponential"),
)
def on_user_created():
"""Send welcome email — retried on failure, decoupled from mutation."""
pass

Defines an Arrow Flight query.

@fraiseql.arrow_query(sql_source="va_analytics")
def analytics_data(
start_date: Date,
end_date: Date
) -> list[AnalyticsRow]:
"""Query analytics via Arrow Flight."""
pass

Field-level configuration.

fraiseql.field(
requires_scope="scope", # Required scope
deprecated="message", # Deprecation message
default=value, # Default value
filterable=True, # Include in WhereInput
orderable=True, # Include in OrderByInput
computed=False, # Is computed (not in DB)
filter_type=CustomFilter, # Custom filter type
filter_operators=["_eq"], # Allowed operators
)

Input validation rules.

fraiseql.validate(
pattern="regex", # Regex pattern
min_length=1, # Minimum string length
max_length=100, # Maximum string length
minimum=0, # Minimum number value
maximum=100, # Maximum number value
enum=["a", "b"], # Allowed values
custom=validator_func, # Custom validator
message="Error message", # Custom error message
)
@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

Export schema to JSON:

## Quick Reference
### Decorator Cheat Sheet
| Decorator | Purpose | Key Options |
|-----------|---------|-------------|
| `@type` | Define object type | `database`, `implements` |
| `@input` | Define input type ||
| `@query` | Define query | `sql_source`, `auto_params`, `cache_ttl` |
| `@mutation` | Define mutation | `sql_source`, `operation`, `requires_scope` |
| `@subscription` | Define subscription | `entity_type`, `filter`, `topic` |
| `@enum` | Define enum ||
| `@interface` | Define interface ||
| `@union` | Define union | `members` |
| `@scalar` | Define custom scalar ||
| `@observer` | Event observer | `entity`, `event`, `condition`, `actions` |
| `@role` | Define role ||
| `@middleware` | Request middleware ||
| `@before_mutation` | Pre-mutation hook ||
| `@after_mutation` | Post-mutation hook ||
| `@arrow_query` | Arrow Flight query | `sql_source` |
### Common Auto Params
```python
@fraiseql.query(
sql_source="v_user",
auto_params={
"limit": True, # Add limit: Int parameter
"offset": True, # Add offset: Int parameter
"where": True, # Add where: UserWhereInput
"order_by": True, # Add orderBy: UserOrderByInput
"total_count": True, # Wrap result with totalCount
}
)
# With Annotated
from typing import Annotated
@fraiseql.type
class User:
id: ID
email: Annotated[str, fraiseql.field(
requires_scope="user:read_email", # Require scope
deprecated="Use contactEmail instead", # Deprecation
default="", # Default value
computed=True, # Computed field
)]
@fraiseql.input
class CreateUserInput:
email: Annotated[str, fraiseql.validate(
pattern=r"^[^@]+@[^@]+\.[^@]+$",
message="Invalid email format"
)]
name: Annotated[str, fraiseql.validate(
min_length=2,
max_length=100
)]
age: Annotated[int, fraiseql.validate(
minimum=13,
maximum=120
)]
  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(sql_source="v_product")
    def product(id: str) -> Product:
    pass
  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