GraphQL Schema Design: Pagination, Mutations, and N+1 Query Prevention

GraphQL's Promise and Its Sharp Edges

GraphQL lets clients request exactly the data they need in a single round trip. No over-fetching, no under-fetching, no versioning headaches. That's the pitch, and it's real — once you've worked with a well-designed GraphQL API, going back to REST feels clunky.

But GraphQL also gives you new ways to shoot yourself in the foot. N+1 queries, unbounded complexity, schema sprawl. Let's walk through the patterns that prevent these problems.

Cursor-Based Pagination

Offset pagination (LIMIT 20 OFFSET 100) has a well-known problem: as the offset grows, the database still scans all preceding rows. Page 500 of results is dramatically slower than page 1.

Cursor-based pagination avoids this by using an opaque cursor (typically a base64-encoded ID or timestamp) to mark the position in the result set:

type Query {
  users(first: Int!, after: String): UserConnection!
}

type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
}

type UserEdge {
  cursor: String!
  node: User!
}

type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
}

The Relay Connection specification defines this pattern, and I'd recommend following it even if you're not using Relay. It's well-understood, clients know how to handle it, and it scales to millions of records without degradation.

Implementation detail: the cursor should encode enough information to resume the query efficiently. For a simple case:

# Cursor = base64(id)
# Query: SELECT * FROM users WHERE id > $decoded_cursor ORDER BY id LIMIT $first + 1
# Fetch first+1 rows; if you got first+1, hasNextPage=true, endCursor=last.id

Mutation Patterns

Every mutation should follow a consistent pattern. I've settled on this structure after working with several GraphQL APIs:

type Mutation {
  createOrder(input: CreateOrderInput!): CreateOrderPayload!
  updateOrder(input: UpdateOrderInput!): UpdateOrderPayload!
  cancelOrder(input: CancelOrderInput!): CancelOrderPayload!
}

input CreateOrderInput {
  items: [OrderItemInput!]!
  shippingAddressId: ID!
  paymentMethodId: ID!
}

type CreateOrderPayload {
  order: Order
  errors: [UserError!]!
}

type UserError {
  field: [String!]
  message: String!
  code: ErrorCode!
}

Key decisions here:

  • Input types are always separate — even if they look like the return type. Inputs and outputs evolve independently.
  • Payloads include both the result and errors — application-level errors (validation failures, business rule violations) go in the errors field, not as GraphQL errors. GraphQL errors are for infrastructure problems.
  • Specific mutation namescancelOrder is better than a generic updateOrder with a status field. Each mutation maps to a business action.

The N+1 Problem and DataLoader

This is the single most common performance issue in GraphQL. Consider this query:

{
  posts(first: 20) {
    edges {
      node {
        title
        author {       # This resolves per-post
          name
          avatar
        }
      }
    }
  }
}

A naive resolver fetches 20 posts, then makes 20 separate database queries to fetch each post's author. If those 20 posts have 8 unique authors, you're making 12 redundant queries.

DataLoader solves this by batching and caching:

# Python with Strawberry/Ariadne
from promise import Promise
from promise.dataloader import DataLoader

class UserLoader(DataLoader):
    def batch_load_fn(self, user_ids):
        # Single query: SELECT * FROM users WHERE id IN (...)
        users = User.objects.filter(id__in=user_ids)
        user_map = {u.id: u for u in users}
        return Promise.resolve([user_map.get(uid) for uid in user_ids])

# In resolver
def resolve_author(post, info):
    return info.context.user_loader.load(post.author_id)

Now those 20 individual queries become one: SELECT * FROM users WHERE id IN (3, 7, 12, ...). DataLoader batches all calls within a single execution tick and deduplicates IDs.

Create a new DataLoader instance per request (not globally) to prevent stale cache data between requests.

Query Complexity and Depth Limiting

GraphQL's flexibility is also its vulnerability. A malicious client could send a deeply nested query that brings your server to its knees:

{
  users {
    friends {
      friends {
        friends {
          friends {
            # ... 20 levels deep, each multiplying the result set
          }
        }
      }
    }
  }
}

Defend against this with:

  • Depth limiting — reject queries deeper than a threshold (typically 7-10 levels)
  • Complexity analysis — assign a cost to each field and reject queries exceeding a total cost budget
  • Query allowlisting — in production, only accept queries that match pre-registered hashes (persisted queries). This is the most secure option.

Most GraphQL server frameworks have middleware for depth and complexity limiting. Use them from day one, not after you've been DoS'd.

Schema Design Tips

A few things I wish someone had told me earlier:

Use non-nullable types aggressively. String! is better than String when null isn't meaningful. Clients shouldn't have to null-check every field.

Avoid deeply nested input types. Flatten where possible. A mutation with three levels of nested input objects is painful to work with on the client side.

Use enums for finite sets. OrderStatus as an enum is better than a String that happens to be one of five values.

Don't expose your database schema. Your GraphQL schema is a product — design it for the consumers, not as a mirror of your tables.