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.