Use when designing a GraphQL schema, implementing resolvers or mutations, solving the N+1 query problem with DataLoader, setting up subscriptions, paginating with Cursor Connections, securing a GraphQL API, or choosing between GraphQL and REST.
Scanned 9/25/2026
npx -y skills add kid-sid/claude-spellbook --skill graphql --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Graphql?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/kid-sid-graphql)More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.
---
name: graphql
description: Use when designing a GraphQL schema, implementing resolvers or mutations, solving the N+1 query problem with DataLoader, setting up subscriptions, paginating with Cursor Connections, securing a GraphQL API, or choosing between GraphQL and REST.
---
# GraphQL
Schema design, resolver patterns, performance, and security for production GraphQL APIs.
## When to Activate
- Designing types, queries, mutations, or subscriptions in a GraphQL schema
- Implementing resolvers in Python (Strawberry/Ariadne), TypeScript (Apollo/Pothos), or Go (gqlgen)
- Solving N+1 query problems with DataLoader
- Paginating results with Cursor Connections
- Securing a GraphQL endpoint against introspection, depth attacks, or query abuse
- Choosing between GraphQL and REST for a new API
- Setting up real-time updates with subscriptions
---
## GraphQL vs. REST
| Concern | GraphQL | REST |
|---|---|---|
| Data fetching | Client specifies exact fields | Server defines response shape |
| Multiple resources | Single request | One request per resource |
| Versioning | Schema evolves via deprecation | URL or header versioning |
| Caching | Complex (query-level) | Simple (HTTP cache headers) |
| File uploads | Non-standard | Native multipart |
| Best for | Flexible client needs, multiple consumers | Simple CRUD, public APIs, CDN caching |
Use GraphQL when you have multiple clients (web, mobile, third-party) with different data needs. Prefer REST for simple CRUD with aggressive HTTP caching.
---
## Schema Design
### Type Conventions
```graphql
# Scalar types
scalar DateTime # ISO-8601 string
scalar UUID
scalar JSON
# Object type — PascalCase, fields camelCase
type User {
id: ID!
email: String!
createdAt: DateTime!
orders(first: Int, after: String): OrderConnection!
}
# Input type — suffix with Input
input CreateUserInput {
email: String!
name: String!
}
# Enum — SCREAMING_SNAKE_CASE values
enum OrderStatus {
PENDING
PROCESSING
COMPLETED
CANCELLED
}
# Interface — shared fields across types
interface Node {
id: ID!
}
# Union — one of several types
union SearchResult = User | Product | Order
```
### Nullable vs. Non-Null
| Pattern | Schema | When to use |
|---|---|---|
| Always present | `field: String!` | Required data — fetch fails if missing |
| Optional | `field: String` | May legitimately be absent |
| List always present | `items: [Item!]!` | List itself and items always exist |
| List may be absent | `items: [Item!]` | Null means "not loaded", `[]` means "empty" |
**Prefer non-null (`!`) for fields that are always present.** Nullable fields force every client to null-check; use them only when absence is meaningful.
### Mutations
```graphql
# BAD — returns the raw type
type Mutation {
createUser(email: String!, name: String!): User
}
# GOOD — dedicated payload type with errors
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
deleteUser(id: ID!): DeleteUserPayload!
}
type CreateUserPayload {
user: User # null on failure
errors: [UserError!]!
}
type UserError {
field: String # null for non-field errors
message: String!
code: String!
}
```
Mutation payload types give clients a typed error path without relying on the `errors` top-level array.
---
## Cursor Connections (Pagination)
Use the Relay Cursor Connection spec for all list fields — it handles forward, backward, and arbitrary pagination consistently.
```graphql
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
users(first: Int, after: String, last: Int, before: String): UserConnection!
}
```
```python
# Python — cursor is base64(type:id)
import base64
def encode_cursor(type_name: str, id: str) -> str:
return base64.b64encode(f"{type_name}:{id}".encode()).decode()
def decode_cursor(cursor: str) -> tuple[str, str]:
decoded = base64.b64decode(cursor.encode()).decode()
type_name, id = decoded.split(":", 1)
return type_name, id
```
---
## Resolvers and the N+1 Problem
### The Problem
```
Query: { users { id orders { id total } } }
Naive resolver:
SELECT * FROM users — 1 query
SELECT * FROM orders WHERE user_id=1 — 1 query per user
SELECT * FROM orders WHERE user_id=2
SELECT * FROM orders WHERE user_id=3 — N queries for N users = N+1 total
```
### DataLoader (Batch + Cache)
```typescript
// TypeScript — Apollo Server with DataLoader
import DataLoader from "dataloader";
// One loader per request — never share across requests
function createLoaders() {
return {
ordersByUserId: new DataLoader<string, Order[]>(async (userIds) => {
const orders = await db.order.findMany({
where: { userId: { in: [...userIds] } },
});
// Return results in the same order as keys
return userIds.map((id) => orders.filter((o) => o.userId === id));
}),
};
}
// Resolver
const resolvers = {
User: {
orders: (user, _args, { loaders }) =>
loaders.ordersByUserId.load(user.id), // batched automatically
},
};
```
```python
# Python — Strawberry with strawberry-django DataLoader
from strawberry.dataloader import DataLoader
async def load_orders(user_ids: list[str]) -> list[list[Order]]:
orders = await Order.objects.filter(user_id__in=user_ids).all()
mapping: dict[str, list[Order]] = {id: [] for id in user_ids}
for order in orders:
mapping[order.user_id].append(order)
return [mapping[id] for id in user_ids]
orders_loader = DataLoader(load_fn=load_orders)
```
```go
// Go — gqlgen with graph-gophers/dataloader
loader := dataloader.NewBatchedLoader(func(ctx context.Context, keys dataloader.Keys) []*dataloader.Result {
ids := make([]string, len(keys))
for i, k := range keys { ids[i] = k.String() }
orders, _ := db.FindOrdersByUserIDs(ctx, ids)
// Map results back to key order
results := make([]*dataloader.Result, len(keys))
orderMap := groupByUserID(orders)
for i, k := range keys {
results[i] = &dataloader.Result{Data: orderMap[k.String()]}
}
return results
})
```
---
## Subscriptions
```graphql
type Subscription {
orderStatusChanged(orderId: ID!): OrderStatusEvent!
}
type OrderStatusEvent {
orderId: ID!
status: OrderStatus!
updatedAt: DateTime!
}
```
```typescript
// TypeScript — Apollo Server with Redis PubSub
import { RedisPubSub } from "graphql-redis-subscriptions";
const pubsub = new RedisPubSub({
publisher: new Redis(process.env.REDIS_URL),
subscriber: new Redis(process.env.REDIS_URL),
});
const resolvers = {
Subscription: {
orderStatusChanged: {
subscribe: (_root, { orderId }) =>
pubsub.asyncIterableIterator(`ORDER_STATUS:${orderId}`),
},
},
Mutation: {
updateOrderStatus: async (_root, { orderId, status }) => {
const order = await db.order.update({ where: { id: orderId }, data: { status } });
await pubsub.publish(`ORDER_STATUS:${orderId}`, { orderStatusChanged: order });
return order;
},
},
};
```
---
## Security
### Query Complexity and Depth Limits
Without limits, a single query can exhaust server resources:
```graphql
# Depth attack
{ user { friends { friends { friends { friends { id } } } } } }
# Breadth attack — requests thousands of fields
{ users(first: 1000) { orders(first: 1000) { items(first: 1000) { id } } } }
```
```typescript
// TypeScript — graphql-depth-limit + graphql-query-complexity
import depthLimit from "graphql-depth-limit";
import { createComplexityLimitRule } from "graphql-query-complexity";
const server = new ApolloServer({
validationRules: [
depthLimit(5),
createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 2,
listFactor: 10,
}),
],
});
```
### Disable Introspection in Production
```typescript
const server = new ApolloServer({
introspection: process.env.NODE_ENV !== "production",
});
```
### Field-Level Authorization
```python
# Python — Strawberry permission classes
import strawberry
from strawberry.permission import BasePermission
class IsAuthenticated(BasePermission):
message = "Not authenticated"
def has_permission(self, source, info, **kwargs) -> bool:
return info.context.user is not None
class IsAdmin(BasePermission):
message = "Admin access required"
def has_permission(self, source, info, **kwargs) -> bool:
return getattr(info.context.user, "role", None) == "admin"
@strawberry.type
class Query:
@strawberry.field(permission_classes=[IsAuthenticated])
def me(self, info) -> User:
return info.context.user
@strawberry.field(permission_classes=[IsAdmin])
def all_users(self, info) -> list[User]:
return User.objects.all()
```
---
## Schema Evolution
| Change | Safe? | Notes |
|---|---|---|
| Add a field | Yes | Existing clients ignore unknown fields |
| Add a type | Yes | Not exposed until a query uses it |
| Add a non-null argument | No | Breaks clients not passing the argument |
| Add an optional argument | Yes | Default value required |
| Remove or rename a field | No | Deprecate first, remove after migration |
| Change field type | No | Always breaking |
```graphql
# Deprecate before removing — give clients time to migrate
type User {
name: String @deprecated(reason: "Use `firstName` and `lastName` instead")
firstName: String!
lastName: String!
}
```
---
## Red Flags
- **Returning raw errors in the `errors` array for business failures** — use mutation payload types with a typed `errors` field; the top-level `errors` array is for server errors only.
- **No DataLoader for nested list resolvers** — every list field that loads related data without batching causes N+1 queries; instrument with query logging to catch them.
- **Introspection enabled in production** — exposes your full schema to attackers; disable it or restrict to authenticated users.
- **No depth or complexity limits** — a deeply nested query can exhaust CPU and memory; always set limits in validation rules.
- **Nullable everything** — excessive nullability forces clients to null-check every field; use `!` for fields that are always present.
- **Business logic in resolvers** — resolvers become untestable and duplicated; keep resolvers thin and delegate to a service layer.
- **Sharing DataLoader instances across requests** — DataLoaders cache per-request; a shared loader leaks data between users.
- **One mutation per field** — `updateUserName`, `updateUserEmail` as separate mutations is a smell; use `updateUser(input: UpdateUserInput!)` with partial input.
---
## Checklist
- [ ] All list fields use Cursor Connection pagination — no offset-based `skip`/`limit`
- [ ] Mutations return dedicated payload types with a typed `errors` field
- [ ] DataLoader used for every resolver that loads related entities — no N+1 queries
- [ ] Query depth limit set (max 5–7 levels)
- [ ] Query complexity limit set and tuned to realistic usage
- [ ] Introspection disabled in production
- [ ] Field-level authorization applied — not just route-level auth middleware
- [ ] Non-null (`!`) used for fields that are always present — nullable only where absence is meaningful
- [ ] Deprecated fields annotated with `@deprecated(reason: "...")` before removal
- [ ] DataLoader instances created per-request — never shared across requests
- [ ] Subscriptions use a pub/sub backend (Redis) — not in-memory for multi-instance deployments
- [ ] Schema linted with `graphql-inspector` or equivalent in CI
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!