Custom Queries
Learn how to write custom SQL queries beyond the auto-generated ones. Custom Queries
This guide covers both general GraphQL best practices and FraiseQL-specific patterns for designing effective, maintainable APIs.
Your GraphQL schema is a contract with your clients. Design it around client needs, not database structure:
from fraiseql.scalars import ID, Decimal
# Good: Client-focused@fraiseql.typeclass Order: id: ID customer: Customer items: list[OrderItem] total: Decimal status: OrderStatus
# Avoid: Database-focused@fraiseql.typeclass TbOrder: pk_order: int # Exposes internals fk_customer: int # Meaningless to clientsimport { type } from 'fraiseql';
// Good: Client-focused@type()class Order { id: string; customer: Customer; items: OrderItem[]; total: number; status: OrderStatus;}
// Avoid: Database-focused@type()class TbOrder { pkOrder: number; // Exposes internals fkCustomer: number; // Meaningless to clients}Types: PascalCase, singular nouns
class User: ... # Goodclass BlogPost: ... # Goodclass Users: ... # Avoid (plural)class blog_post: ... # Avoid (snake_case)Fields: camelCase (FraiseQL converts from snake_case)
# Pythoncreated_at: DateTimeorder_items: list[OrderItem]
# Becomes GraphQL# createdAt: DateTime!# orderItems: [OrderItem!]!Queries: Noun-based, describe what you get
def user(id: ID) -> User: ... # Single entitydef users(limit: int) -> list[User]: ... # Collectiondef posts_by_author(author_id: ID) -> list[Post]: ... # FilteredMutations: Verb + noun, describe the action
def create_user(...) -> User: ...def update_post(...) -> Post: ...def publish_post(...) -> Post: ... # Domain action, not CRUDdef cancel_order(...) -> Order: ...Be intentional. Null should have meaning:
from fraiseql.scalars import Email, DateTime
@fraiseql.typeclass User: email: Email # Required - every user has email bio: str | None # Optional - not everyone writes a bio deleted_at: DateTime | None # Null = not deleted (meaningful)Never use nullable collections:
posts: list[Post] # Good: empty list if noneposts: list[Post] | None # Bad: null vs [] is confusingList item nullability:
tags: list[Tag] # [Tag!]! - list required, items required (most common)tags: list[Tag | None] # [Tag]! - allows null items (rare, avoid)import { type } from 'fraiseql';
@type()class User { email: string; // Required - every user has email bio?: string; // Optional - not everyone writes a bio deletedAt?: Date; // undefined = not deleted (meaningful)}Never use nullable collections:
posts: Post[]; // Good: empty array if noneposts?: Post[]; // Bad: undefined vs [] is confusingList item nullability:
tags: Tag[]; // [Tag!]! - list required, items required (most common)tags: (Tag | null)[]; // [Tag]! - allows null items (rare, avoid)Use opaque IDs that don’t leak information:
@fraiseql.typeclass User: id: ID # "usr_a1b2c3d4" or UUID - opaque, secureAvoid:
user_admin_1)Composition over references:
# Good: Rich nested data@fraiseql.typeclass Post: author: User # Full user object
# Acceptable for large collections@fraiseql.typeclass User: post_ids: list[ID] # IDs only, fetch separatelyMany-to-many with relationship data:
from fraiseql.scalars import DateTime
@fraiseql.typeclass Membership: user: User team: Team role: str # Data on the relationship joined_at: DateTimeAlways paginate collections:
@fraiseql.query(sql_source="v_post")def posts( limit: int = 20, # Default limit offset: int = 0) -> list[Post]: ...Connection pattern for cursor pagination:
@fraiseql.typeclass PostConnection: edges: list[PostEdge] page_info: PageInfo total_count: int
@fraiseql.typeclass PostEdge: node: Post cursor: strUse input types for complex mutations:
@fraiseql.inputclass CreatePostInput: title: str content: str tags: list[str] | None = None is_draft: bool = True
@fraiseql.mutation(sql_source="fn_create_post")def create_post(input: CreatePostInput) -> Post: ...Partial updates with optional fields:
from fraiseql.scalars import URL
@fraiseql.inputclass UpdateUserInput: name: str | None = None bio: str | None = None avatar_url: URL | None = None # Only provided fields are updatedimport { input, mutation } from 'fraiseql';
@input()class CreatePostInput { title: string; content: string; tags?: string[]; isDraft: boolean = true;}
@mutation()function createPost(input: CreatePostInput): Post {}Partial updates with optional fields:
@input()class UpdateUserInput { name?: string; bio?: string; avatarUrl?: string; // Only provided fields are updated}Use for truly fixed values only:
@fraiseql.enumclass OrderStatus(Enum): PENDING = "pending" SHIPPED = "shipped" DELIVERED = "delivered"
# Don't use enums for:# - Categories (change over time)# - Tags (user-created)# - Roles (configurable)import { fraiseqlEnum } from '@fraiseql/sdk';
@fraiseqlEnum()enum OrderStatus { PENDING = 'pending', SHIPPED = 'shipped', DELIVERED = 'delivered',}
// Don't use enums for:// - Categories (change over time)// - Tags (user-created)// - Roles (configurable)package schema
import "github.com/fraiseql/fraiseql-go"
type OrderStatus string
const ( OrderStatusPending OrderStatus = "pending" OrderStatusShipped OrderStatus = "shipped" OrderStatusDelivered OrderStatus = "delivered")
func init() { fraiseql.RegisterEnum[OrderStatus]() // Don't use enums for categories, tags, or configurable roles}Safe changes:
@deprecated)Breaking changes (avoid):
# Deprecation pattern@fraiseql.typeclass User: name: str username: Annotated[str, fraiseql.field( deprecated="Use 'name' instead. Will be removed in v3." )]FraiseQL provides 47+ domain-specific scalar types with built-in validation and specialized filter operators. Use them instead of generic str:
from fraiseql.scalars import Email, URL, PhoneNumber, CountryCode, DateTime, Decimal
@fraiseql.typeclass Customer: id: ID email: Email # RFC 5322 validated, filters: _domain_eq, _is_corporate website: URL | None # RFC 3986 validated, filters: _domain_eq, _is_secure phone: PhoneNumber # E.164 format, filters: _country_code_eq, _is_mobile country: CountryCode # ISO 3166-1, filters: _in_eu, _gdpr_applicable created_at: DateTime balance: Decimal # Arbitrary precision for financial dataimport { type, field } from '@fraiseql/sdk';
@type()class Customer { @field() id!: string; @field() email!: string; // RFC 5322 validated @field() website?: string; // RFC 3986 validated @field() phone!: string; // E.164 format @field() country!: string; // ISO 3166-1 @field() createdAt!: Date; @field() balance!: string; // Arbitrary precision for financial data}package schema
import "github.com/fraiseql/fraiseql-go"
type Customer struct { ID string `fraiseql:"id"` Email string `fraiseql:"email"` // RFC 5322 validated Website *string `fraiseql:"website"` // RFC 3986 validated Phone string `fraiseql:"phone"` // E.164 format Country string `fraiseql:"country"` // ISO 3166-1 CreatedAt string `fraiseql:"created_at"` Balance string `fraiseql:"balance"` // Arbitrary precision for financial data}
func init() { fraiseql.RegisterType[Customer]()}Benefits:
email: { _is_corporate: true })See Scalar Types for the complete list.
FraiseQL separates reads and writes. Design your schema accordingly:
Queries read from views (v_, tv_):
@fraiseql.query(sql_source="v_user") # Reads from viewdef user(id: ID) -> User: ...
@fraiseql.query(sql_source="tv_user") # Reads from materialized tabledef users(limit: int = 20) -> list[User]: ...import { query } from 'fraiseql';
@query({ sqlSource: 'v_user' }) // Reads from viewfunction user(id: string): User {}
@query({ sqlSource: 'tv_user' }) // Reads from materialized tablefunction users(limit: number = 20): User[] {}Mutations write through functions (fn_):
from fraiseql.scalars import Email
@fraiseql.mutation(sql_source="fn_create_user")def create_user(email: Email, name: str) -> User: ...import { mutation } from 'fraiseql';
@mutation()function createUser(email: string, name: string): User {}Implication: Your read types can be more denormalized than your write inputs. The view handles the denormalization.
Views embed other views via .data. This affects type design:
Avoid cycles: If Post embeds User (author), User cannot embed Post (posts):
# v_post embeds full author@fraiseql.typeclass Post: author: User # Full user data from v_user.data
# v_user returns post IDs only (breaks the cycle)@fraiseql.typeclass User: post_ids: list[ID] # Not full Post objectsEvery entity has three identifiers. Only expose two:
from fraiseql.scalars import ID, Slug
@fraiseql.typeclass User: id: ID # UUID - external, exposed in API identifier: Slug # Human-readable (username, slug) # pk_user: int # NEVER expose - internal onlyDesign queries to support both:
@fraiseql.query(sql_source="v_user")def user(id: ID | None = None, identifier: Slug | None = None) -> User | None: """Fetch by UUID or human-readable identifier.""" passimport { type, query } from 'fraiseql';
@type()class User { id: string; // UUID - external, exposed in API identifier: string; // Human-readable (username, slug) // pkUser: number // NEVER expose - internal only}Design queries to support both:
@query({ sqlSource: 'v_user' })function user(id?: string, identifier?: string): User | null { /** Fetch by UUID or human-readable identifier. */}FraiseQL generates filter types automatically. Design knowing this:
from fraiseql.scalars import DateTime
# Your type@fraiseql.typeclass Post: title: str is_published: bool created_at: DateTime
# FraiseQL generates PostWhere with:# - title_eq, title_contains, title_starts_with# - is_published_eq# - created_at_gt, created_at_lt, created_at_gte, created_at_lteIndex accordingly (SQL applies to all SDKs — the database layer is the same regardless of whether you use Python or TypeScript for schema authoring):
-- Index fields you expect to filter onCREATE INDEX idx_post_published ON tb_post(is_published);CREATE INDEX idx_post_created ON tb_post(created_at DESC);CREATE INDEX idx_tv_post_title ON tv_post((data->>'title'));Some views should be materialized to tv_ tables. Design for this:
Good candidates for tv_:
Keep as v_ views:
# Hot path - use tv_@fraiseql.query(sql_source="tv_post")def featured_posts() -> list[Post]: ...
# Rare access - use v_@fraiseql.query(sql_source="v_audit_log")def audit_logs(user_id: ID) -> list[AuditLog]: ...Mutations should only change data. Side effects belong in observers:
# Mutation: just creates the order@fraiseql.mutation(sql_source="fn_create_order", operation="CREATE")def create_order(input: CreateOrderInput) -> Order: """Creates order. Notification handled by observer.""" pass# Observer: handles side effects@fraiseql.observer( entity_type="Order", on=["CREATE"], actions=[ SendEmail(template="order_confirmation"), CallWebhook(url="${WEBHOOK_URL}"), NotifySlack(channel="#orders") ])def on_order_created(order: Order): ...Don’t design mutations expecting synchronous side effects. The API returns immediately; side effects happen asynchronously.
FraiseQL compiles your schema to SQL. There are no runtime resolvers:
from fraiseql.scalars import ID, Email
# Good: All fields come from the view@fraiseql.typeclass User: id: ID name: str email: Email post_count: int # Computed in the view
# Cannot do: Runtime-computed fields@fraiseql.typeclass User: id: ID name: str greeting: str # "Hello, {name}" - no runtime resolvers!import { type, field } from '@fraiseql/sdk';
// Good: All fields come from the view@type()class User { @field() id!: string; @field() name!: string; @field() email!: string; @field() postCount!: number; // Computed in the view}
// Cannot do: Runtime-computed fields// @type()// class User {// @field() id!: string;// @field() name!: string;// @field() greeting!: string; // No runtime resolvers!// }package schema
import "github.com/fraiseql/fraiseql-go"
// Good: All fields come from the viewtype User struct { ID string `fraiseql:"id"` Name string `fraiseql:"name"` Email string `fraiseql:"email"` PostCount int `fraiseql:"post_count"` // Computed in the view}
// Cannot do: fields not backed by SQL columns// type User struct {// ID string `fraiseql:"id"`// Name string `fraiseql:"name"`// Greeting string `fraiseql:"greeting"` // No runtime resolvers!// }
func init() { fraiseql.RegisterType[User]()}Solution: Compute in the view (this SQL is SDK-agnostic — write it the same way whether you use Python or TypeScript):
CREATE VIEW v_user ASSELECT id, jsonb_build_object( 'id', id, 'name', name, 'greeting', 'Hello, ' || name -- Computed in SQL ) AS dataFROM tb_user;Pre-compute aggregations:
-- In v_user, include countsjsonb_build_object( 'id', u.id, 'name', u.name, 'post_count', (SELECT COUNT(*) FROM tb_post WHERE fk_user = u.pk_user), 'follower_count', (SELECT COUNT(*) FROM tb_follow WHERE fk_followed = u.pk_user)) AS dataLimit nested depth:
# Good: Shallow nesting@fraiseql.typeclass Post: author: User # 1 level deep
# Avoid: Deep nesting@fraiseql.typeclass Comment: post: Post # Post includes author... # author: User # who includes posts... # posts: [Post] # which include comments...Use IDs for deep relationships:
@fraiseql.typeclass Comment: post_id: ID # Just the ID author_id: ID # Client fetches separately if needed# Avoid@fraiseql.typeclass User: pk_user: int # Internal ID fk_org: int # Internal FK tb_user_id: int # Table prefix# Avoid: 50+ fields@fraiseql.typeclass User: # ... endless fields
# Better: Split into focused types@fraiseql.typeclass User: id: ID profile: UserProfile settings: UserSettings security: UserSecurity# Cannot work in FraiseQL@fraiseql.typeclass User: posts: list[Post] # Full posts
@fraiseql.typeclass Post: author: User # Full user (cycle!)# Wrong mental modeldef create_order(input: CreateOrderInput) -> Order: # Mutation returns, THEN email sends # Don't promise "order created and email sent" passThe tb_* / v_* / fn_* convention is a recommendation, not a constraint. The compile step accepts any SQL source you point it at.
fraiseql compile to auto-discover views with zero configFor small projects or prototypes, you can point sql_source at a table directly — no view required:
# Direct table reference — no v_ view needed for simple cases@fraiseql.query(sql_source="posts") # points at the posts tabledef posts() -> list[Post]: ...FraiseQL treats a table and a view identically at compile time. The compile step will warn if the source doesn’t return JSONB-shaped data, but for simple flat tables this works immediately.
You can also mix conventions — use v_* views for complex queries and point directly at tables for admin-only or internal endpoints.
Before finalizing your schema:
tv_ tables?Custom Queries
Learn how to write custom SQL queries beyond the auto-generated ones. Custom Queries
CQRS Pattern
Understand the write vs read separation at the heart of FraiseQL. CQRS Pattern
View Composition
Deep dive into how views compose to build rich nested types. View Composition
Performance
Optimize your FraiseQL API for high-throughput production workloads. Performance