Eliminate N+1 Queries Forever
Compose relationships in views, not resolvers. One query per request.
The N+1 Problem: Classic GraphQL Trap
Typical GraphQL Resolver Pattern
# Fetch user
@resolver
async def user(info, id: str) -> User:
return db.find_user(id) # Query 1
# User has posts (needs resolver)
@resolver
async def posts(parent: User, info) -> list[Post]:
return db.find_posts(user_id=parent.id) # Query 2 per user!
# Result: 1 user query + N posts queries = 1 + N queries Problem: Fetch 100 users and you get 101 database queries. DataLoaders help, but they add complexity and still require careful batching.
101
Database hits
for 100 users + posts
2-5s
Response time
typical latency
50%
Code complexity
DataLoader boilerplate
FraiseQL Solution: Compose at Database Level
Views eliminate N+1 by composing relationships once in SQL:
Step 1: SQL View Composes Everything
CREATE VIEW v_user_with_posts AS
SELECT
jsonb_build_object(
'id', u.id::text,
'name', u.name,
'email', u.email,
'posts', (
SELECT jsonb_agg(jsonb_build_object(
'id', p.id::text,
'title', p.title,
'created_at', p.created_at
))
FROM tb_post p
WHERE p.user_id = u.id
)
) AS data
FROM tb_user u; Step 2: Single Resolver Query
@fraiseql.query(sql_source="v_user_with_posts")
def users(limit: int = 100) -> list[User]:
# One query returns everything — pre-composed in the view
pass Step 3: Python Type Matches View
@fraiseql.type
class UserPost:
id: ID
title: str
created_at: str
@fraiseql.type
class User:
id: ID
name: str
email: str
posts: list[UserPost] Result
100 users + all their posts = 1 database query
Response time: ~300ms vs 2-5s before
Side-by-Side Comparison
❌ Traditional GraphQL
- Fetch users → 1 query
- Load posts per user → N queries
- Try to batch with DataLoader
- Still need careful resolver design
- Total: ~100+ queries
- Latency: 2-5s typical
✅ FraiseQL
- SQL view pre-composes everything
- Single resolver query
- No batching complexity
- Deterministic execution plan
- Total: 1 query
- Latency: ~300ms
Complex Composition Examples
Posts with Comments and Likes
CREATE VIEW v_posts_full AS
SELECT
jsonb_build_object(
'id', p.id::text,
'title', p.title,
'comments', (
SELECT jsonb_agg(jsonb_build_object(
'id', c.id::text,
'text', c.text,
'author', jsonb_build_object(
'id', u.id::text,
'name', u.name
),
'likes', (SELECT count(*) FROM tb_like WHERE comment_id = c.id)
))
FROM tb_comment c
LEFT JOIN tb_user u ON c.user_id = u.id
WHERE c.post_id = p.id
),
'like_count', (SELECT count(*) FROM tb_like WHERE post_id = p.id)
) AS data
FROM tb_post p; Single query returns: Post → Comments → Comment Author + Comment Likes + Post Likes
Nested E-Commerce Data
CREATE VIEW v_orders_with_details AS
SELECT
jsonb_build_object(
'id', o.id::text,
'order_date', o.created_at,
'items', (
SELECT jsonb_agg(jsonb_build_object(
'product_id', oi.product_id::text,
'product_name', p.name,
'category', p.category,
'price', oi.price,
'quantity', oi.quantity
))
FROM tb_order_item oi
JOIN tb_product p ON oi.product_id = p.id
WHERE oi.order_id = o.id
),
'shipping_address', jsonb_build_object(
'street', a.street,
'city', a.city,
'zip', a.zip
)
) AS data
FROM tb_order o
JOIN tb_address a ON o.address_id = a.id; Single query returns: Order → Items → Product Details → Shipping Address
Real-World Performance Impact
Query Count
101
Traditional
→
1
FraiseQL
Response Time
2-5s
With DataLoader
→
~300ms
FraiseQL
Resolver Code
47
Custom patterns
→
1
Repeatable pattern
When to Use This Pattern
✅ Use Database Composition
- Related data from same database
- Reads that need aggregation
- Relationships with consistent cardinality
- Performance-critical queries
⚠️ Consider Per-Field Resolvers When
- Data comes from external APIs
- Complex business logic needed
- Field rarely queried (expensive composition)
- Conditional field availability
Best practice: Pre-compose reads in views. Put business logic in SQL functions (fn_*) that back your mutations.
Stop the N+1 Trap
Build APIs that scale naturally with database-first composition.