Developer-Owned SQL
Why you write the SQL views and how FraiseQL maps them.
FraiseQL lets you define your GraphQL schema in your preferred programming language. Each type maps to a SQL view you’ve written (e.g., User maps to v_user). This page covers the Python syntax; other languages follow similar patterns.
import fraiseql
@fraiseql.typeclass User: """A user in the system.""" id: str name: str email: str created_at: strThe docstring becomes the GraphQL type description.
| Python Type | GraphQL Type |
|---|---|
str | String! |
int | Int! |
float | Float! |
bool | Boolean! |
str | None | String |
list[str] | [String!]! |
list[str] | None | [String!] |
@fraiseql.typeclass User: id: str name: str bio: str | None # Optional field@fraiseql.typeclass User: id: str tags: list[str] # Required list nicknames: list[str] | None # Optional listFraiseQL’s convention is to give every entity three distinct identifiers. This is called the Trinity Pattern.
pk_user BIGINT -- internal join key, never leaves the databaseid UUID -- public API identifier, safe to exposeidentifier TEXT -- human-readable (email, username), can changeMost systems conflate internal keys, public identifiers, and human-readable handles. When they’re the same column, you face trade-offs:
| If you use… | Problem when… |
|---|---|
| Auto-increment int as public ID | Users can enumerate records by incrementing |
| UUID as join key | Foreign keys are 36 bytes; index bloat at scale |
| Email as primary key | User changes email → cascading FK updates |
The Trinity Pattern uses each identifier for what it’s good at:
pk_user BIGINT — fast joins, compact indexes, never leaves the databaseid UUID — public API identifier, safe to expose, non-enumerableidentifier TEXT — human input (email, username), can change without touching foreign keysFraiseQL enforces this at compile time when relay=True is set: the view must include pk_{entity} in its JSONB output. The pk is used for cursor generation and stripped before the response leaves the server.
@fraiseql.typeclass User: id: str name: str posts: list['Post'] # User has many posts
@fraiseql.typeclass Post: id: str title: str author: User # Post belongs to user@fraiseql.typeclass Post: id: str title: str tags: list['Tag']
@fraiseql.typeclass Tag: id: str name: str posts: list['Post']You create the join table (tb_post_tag) in your database schema. FraiseQL maps the relationship through your SQL view, which handles the join.
@fraiseql.typeclass Category: id: str name: str parent: 'Category | None' children: list['Category']For mutations, define input types:
@fraiseql.inputclass CreateUserInput: name: str email: str bio: str | None = None # Optional with default
@fraiseql.inputclass UpdateUserInput: name: str | None = None email: str | None = None bio: str | None = Noneimport enum
class UserRole(enum.Enum): ADMIN = "admin" USER = "user" GUEST = "guest"
@fraiseql.typeclass User: id: str name: str role: UserRolefrom datetime import datetimefrom decimal import Decimal@fraiseql.typeclass Order: id: ID total: Decimal created_at: datetimeBuilt-in scalar mappings:
| Python Type | GraphQL Scalar |
|---|---|
datetime | DateTime |
date | Date |
time | Time |
Decimal | Decimal |
UUID | UUID |
FraiseQL creates default query endpoints for each type, mapped to the corresponding SQL view:
users — List query (SELECT data FROM v_user)user(id: ID!) — Single record query (SELECT data FROM v_user WHERE id = $1)posts — List query (SELECT data FROM v_post)post(id: ID!) — Single record query (SELECT data FROM v_post WHERE id = $1)@fraiseql.queryasync def active_users(info, limit: int = 10) -> list[User]: """Get recently active users.""" return await info.context.db.query( "SELECT * FROM v_user WHERE last_seen > now() - interval '1 day' LIMIT $1", limit )
@fraiseql.queryasync def search_posts(info, query: str) -> list[Post]: """Search posts by title or content.""" return await info.context.db.query( "SELECT * FROM v_post WHERE title ILIKE $1 OR content ILIKE $1", f"%{query}%" )FraiseQL creates default mutation endpoints mapped to SQL functions you write (fn_create_user, fn_update_user, fn_delete_user).
@fraiseql.mutationasync def publish_post(info, id: str) -> Post: """Publish a draft post.""" await info.context.db.execute( "UPDATE posts SET published = true WHERE id = $1", id ) return await info.context.db.find_one("v_post", id=id)
@fraiseql.mutationasync def archive_user(info, id: str) -> bool: """Archive a user and their content.""" async with info.context.db.transaction(): await info.context.db.execute( "UPDATE users SET archived = true WHERE id = $1", id ) await info.context.db.execute( "UPDATE posts SET published = false WHERE author_id = $1", id ) return True@fraiseql.typeclass User: id: str name: str email: str = fraiseql.field(private=True) # Not in public API password_hash: str = fraiseql.field(exclude=True) # Never exposed| Option | Effect | Use Case |
|---|---|---|
private=True | Field exists in the schema but requires authentication | Internal fields, PII |
exclude=True | Field excluded from the GraphQL schema entirely | DB-only columns |
@fraiseql.typeclass User: id: str first_name: str last_name: str
@fraiseql.computed def full_name(self) -> str: return f"{self.first_name} {self.last_name}"@fraiseql.typeclass User: id: str name: str username: str = fraiseql.field(deprecated="Use 'name' instead")import { type, input, query, enumType } from 'fraiseql';
// Enum@enumType()enum UserRole { ADMIN = 'admin', USER = 'user', GUEST = 'guest',}
// Object types with relationships@type()class User { id: string; name: string; email: string; role: UserRole; posts: Post[]; // one-to-many bio?: string; // optional field}
@type()class Post { id: string; title: string; content: string; author: User; // many-to-one tags: Tag[]; // many-to-many}
@type()class Tag { id: string; name: string; posts: Post[];}
// Input type@input()class CreateUserInput { name: string; email: string; role?: UserRole;}package schema
// Enum — use a typed string constant grouptype UserRole string
const ( UserRoleAdmin UserRole = "admin" UserRoleUser UserRole = "user" UserRoleGuest UserRole = "guest")
// Object types with relationshipstype User struct { ID string `fraiseql:"id"` Name string `fraiseql:"name"` Email string `fraiseql:"email"` Role UserRole `fraiseql:"role"` Posts []Post `fraiseql:"posts"` // one-to-many Bio *string `fraiseql:"bio"` // optional (pointer = nullable)}
type Post struct { ID string `fraiseql:"id"` Title string `fraiseql:"title"` Content string `fraiseql:"content"` Author User `fraiseql:"author"` // many-to-one Tags []Tag `fraiseql:"tags"` // many-to-many}
type Tag struct { ID string `fraiseql:"id"` Name string `fraiseql:"name"` Posts []Post `fraiseql:"posts"`}Define a simple type in your schema:
import fraiseql
@fraiseql.typeclass Product: """A product in the catalog.""" id: str name: str price: float in_stock: boolCompile the schema:
fraiseql compileYou should see output like:
Compiling schema...✓ Parsed 1 types✓ Generated GraphQL schema✓ Wrote schema.compiled.jsonStart FraiseQL and introspect:
fraiseql run &
# Introspect the schemacurl -X POST http://localhost:8080/graphql \ -H "Content-Type: application/json" \ -d '{"query": "{ __type(name: \"Product\") { fields { name type { name } } } }"}'Expected response:
{ "data": { "__type": { "fields": [ {"name": "id", "type": {"name": "String"}}, {"name": "name", "type": {"name": "String"}}, {"name": "price", "type": {"name": "Float"}}, {"name": "inStock", "type": {"name": "Boolean"}} ] } }}Test a relationship:
@fraiseql.typeclass Category: id: str name: str products: list['Product']
@fraiseql.typeclass Product: id: str name: str category: CategoryCompile again and verify:
fraiseql compilefraiseql runTest queries are generated:
curl -X POST http://localhost:8080/graphql \ -H "Content-Type: application/json" \ -d '{"query": "{ __schema { queryType { fields { name } } } }"}' | jq '.data.__schema.queryType.fields[].name'Should include:
"product""products""category""categories"Organize your schema into logical modules:
schema/├── __init__.py├── users.py # User, Profile, Authentication├── products.py # Product, Category, Inventory├── orders.py # Order, LineItem, Payment└── common.py # Shared types, scalarsAlways include docstrings for complex types:
@fraiseql.typeclass Order: """ Represents a customer order.
The order lifecycle: 1. Created (pending) 2. Payment processed 3. Shipped 4. Delivered (or Cancelled)
Use the `status` field to track order state. """ id: str status: OrderStatus # pending, processing, shipped, delivered, cancelled total: Decimal line_items: list['LineItem']Follow GraphQL conventions (camelCase in GraphQL, snake_case in Python):
@fraiseql.typeclass User: # Python: snake_case created_at: DateTime # Maps to createdAt in GraphQL first_name: str # Maps to firstName in GraphQL is_active: bool # Maps to isActive in GraphQLAdd validation at the schema level:
from typing import Annotated
@fraiseql.inputclass CreateUserInput: email: Annotated[str, fraiseql.validate( pattern=r'^[^@]+@[^@]+\.[^@]+$', message="Invalid email format" )] age: Annotated[int, fraiseql.validate( minimum=13, maximum=120 )]“Type not found”:
Error: Type 'Product' referenced but not defined'Product'“Field has no type annotation”:
Error: Field 'name' in User has no type annotationstr, int, float, bool for basic types“Cannot resolve field”:
Error: Cannot resolve field 'category' on type 'Product'v_product) has the field“Type mismatch”:
Error: Expected String, got Int for field 'price'Decimal for monetary values, not floatDeveloper-Owned SQL
Why you write the SQL views and how FraiseQL maps them.
CQRS Pattern
The table/view separation at the heart of FraiseQL.
TOML Configuration
Configure your project with a simple TOML file.
Queries and Mutations
Full API reference for GraphQL queries and mutations.