crefft Source 2026.10.4

GraphQL Best Practices

crefft > API Design > GraphQL

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.

This guide covers language-agnostic GraphQL best practices for schema design, queries, mutations, and API security. For framework-specific implementation, see:

Why GraphQL?

GraphQL1 is a query language for APIs that gives clients the power to request exactly the data they need. Unlike REST, GraphQL uses a single endpoint and lets clients specify their data requirements through queries.

Use GraphQL when:

  • Clients have diverse data needs (mobile vs. web)
  • Reducing over-fetching and under-fetching improves performance
  • You need real-time updates via subscriptions
  • Strong typing and introspection benefit your developer experience

Consider REST when:

  • Caching is critical (HTTP caching is simpler with REST)
  • Your API is simple with predictable access patterns
  • You’re building public APIs where simplicity matters
  • File uploads are the primary use case

Schema Design

Naming Conventions

Projects MUST follow GraphQL naming conventions:

ElementConventionExample
TypesPascalCaseUser, OrderItem
FieldscamelCasefirstName, createdAt
ArgumentscamelCaseuserId, includeDeleted
EnumsSCREAMING_SNAKE_CASE valuesORDER_STATUS, PENDING
MutationsverbNouncreateUser, deleteOrder
Queriesnoun or getNounuser, users, getOrderById
# Good
type User {
  id: ID!
  firstName: String!
  lastName: String!
  createdAt: DateTime!
}

enum OrderStatus {
  PENDING
  PROCESSING
  SHIPPED
  DELIVERED
}

type Query {
  user(id: ID!): User
  users(status: UserStatus): [User!]!
}

type Mutation {
  createUser(input: CreateUserInput!): User!
  updateUser(id: ID!, input: UpdateUserInput!): User!
  deleteUser(id: ID!): Boolean!
}

Why: Consistent naming makes schemas predictable and self-documenting. These conventions match the GraphQL specification examples and community standards.

Non-Nullable by Design

Projects SHOULD make fields non-nullable by default, using nullable only when needed:

# Good: Non-nullable by default
type User {
  id: ID!
  email: String!
  name: String!
  bio: String          # Nullable: users may not have a bio
  deletedAt: DateTime  # Nullable: null means not deleted
}

# Good: Non-nullable lists with non-nullable elements
type Query {
  users: [User!]!      # Never null, never contains null
}

Why: Non-nullable fields simplify client code by eliminating null checks. Reserve nullability for fields that genuinely might not exist.

List Nullability

Projects MUST understand list nullability patterns:

DeclarationMeaning
[User!]!Non-null list of non-null users (recommended)
[User]!Non-null list that may contain null users
[User!]Nullable list of non-null users
[User]Nullable list that may contain null users

Recommendation: Use [Type!]! for most lists. Return empty arrays instead of null.

Input Types

Projects MUST use input types for mutations with complex arguments:

# Good: Dedicated input type
input CreateUserInput {
  email: String!
  password: String!
  name: String!
  role: UserRole = USER
}

input UpdateUserInput {
  email: String
  name: String
  role: UserRole
}

type Mutation {
  createUser(input: CreateUserInput!): CreateUserPayload!
  updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
}

Why: Input types group related arguments, enable reuse, and make schema evolution easier. They also clarify which fields are required for creation vs. optional for updates.

Payload Types

Projects SHOULD return payload types from mutations:

type CreateUserPayload {
  user: User
  errors: [UserError!]!
}

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

enum ErrorCode {
  VALIDATION_ERROR
  NOT_FOUND
  UNAUTHORIZED
  CONFLICT
}

type Mutation {
  createUser(input: CreateUserInput!): CreateUserPayload!
}

Why: Payload types allow returning both data and errors in a type-safe way. This “errors as data” pattern gives clients structured error information without relying on GraphQL’s error array.

Query Design

Pagination

Projects MUST implement pagination for list queries. Projects SHOULD use cursor-based pagination (Relay Connection spec)2:

type Query {
  users(
    first: Int
    after: String
    last: Int
    before: String
    filter: UserFilter
  ): UserConnection!
}

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

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

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

Why: Cursor-based pagination is stable under insertions/deletions, unlike offset-based pagination. The Connection pattern is a GraphQL community standard supported by many client libraries.

Alternative: For simpler use cases, offset-based pagination is acceptable:

type Query {
  users(limit: Int = 20, offset: Int = 0): UserPage!
}

type UserPage {
  items: [User!]!
  totalCount: Int!
  hasMore: Boolean!
}

Filtering and Sorting

Projects SHOULD use input types for complex filtering:

input UserFilter {
  status: UserStatus
  createdAfter: DateTime
  createdBefore: DateTime
  search: String
  roles: [UserRole!]
}

enum UserSortField {
  CREATED_AT
  NAME
  EMAIL
}

enum SortDirection {
  ASC
  DESC
}

input UserSort {
  field: UserSortField!
  direction: SortDirection = ASC
}

type Query {
  users(
    filter: UserFilter
    sort: UserSort
    first: Int = 20
    after: String
  ): UserConnection!
}

Field Arguments

Projects SHOULD add arguments to fields when transformations are needed:

type User {
  id: ID!
  name: String!
  avatarUrl(size: Int = 100): String!
  createdAt(format: DateFormat = ISO8601): String!
}

enum DateFormat {
  ISO8601
  UNIX_TIMESTAMP
  RELATIVE
}

Mutation Design

Verb-Noun Naming

Projects MUST name mutations with verb-noun format:

# Good
type Mutation {
  createUser(input: CreateUserInput!): CreateUserPayload!
  updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
  deleteUser(id: ID!): DeleteUserPayload!
  sendPasswordReset(email: String!): SendPasswordResetPayload!
  approveOrder(id: ID!): ApproveOrderPayload!
}

# Bad
type Mutation {
  user(input: UserInput!): User!        # Unclear action
  userUpdate(id: ID!): User!            # Wrong order
  doSendEmail(to: String!): Boolean!    # Redundant "do"
}

Idempotency

Projects SHOULD design mutations to be idempotent where possible:

type Mutation {
  # Idempotent: Setting to a specific state
  setOrderStatus(id: ID!, status: OrderStatus!): Order!

  # Idempotent: Using idempotency keys
  createPayment(
    input: CreatePaymentInput!
    idempotencyKey: String!
  ): CreatePaymentPayload!
}

Why: Idempotent mutations can be safely retried on network failures without causing duplicate side effects.

Batch Operations

Projects MAY provide batch mutations for performance:

type Mutation {
  # Single operation
  deleteUser(id: ID!): DeleteUserPayload!

  # Batch operation
  deleteUsers(ids: [ID!]!): DeleteUsersPayload!
}

type DeleteUsersPayload {
  deletedIds: [ID!]!
  errors: [BatchError!]!
}

type BatchError {
  id: ID!
  message: String!
}

Error Handling

Errors as Data

Projects SHOULD use union types or payload types for expected errors:

union CreateUserResult = User | ValidationErrors | EmailTakenError

type ValidationErrors {
  errors: [FieldError!]!
}

type FieldError {
  field: String!
  message: String!
}

type EmailTakenError {
  message: String!
  suggestedEmail: String
}

type Mutation {
  createUser(input: CreateUserInput!): CreateUserResult!
}

Client handling:

mutation CreateUser($input: CreateUserInput!) {
  createUser(input: $input) {
    ... on User {
      id
      email
    }
    ... on ValidationErrors {
      errors {
        field
        message
      }
    }
    ... on EmailTakenError {
      message
      suggestedEmail
    }
  }
}

Why: Union types make error cases explicit and type-safe. Clients must handle each case, preventing silent failures.

GraphQL Errors

Reserve the GraphQL errors array for unexpected errors:

  • Server errors (database down, network issues)
  • Authentication failures (not logged in)
  • Authorization failures (not permitted)
  • Invalid queries (syntax errors, unknown fields)
{
  "errors": [
    {
      "message": "Not authenticated",
      "extensions": {
        "code": "UNAUTHENTICATED"
      }
    }
  ]
}

Security

Query Depth Limiting

Projects MUST limit query depth to prevent deeply nested attacks:

# Without depth limiting, this could be exploited:
query MaliciousQuery {
  users {
    orders {
      user {
        orders {
          user {
            orders {
              # ... infinite nesting
            }
          }
        }
      }
    }
  }
}

Recommendation: Set maximum depth to 10-15 for most applications.

Query Complexity Analysis

Projects SHOULD implement query complexity analysis:

# Assign costs to fields
type Query {
  user(id: ID!): User      # Cost: 1
  users(first: Int): [User!]!  # Cost: first * 1
}

type User {
  id: ID!                  # Cost: 0
  orders: [Order!]!        # Cost: 10 (expensive)
}

Reject queries exceeding a maximum cost threshold.

Rate Limiting

Projects MUST implement rate limiting:

  • Limit by IP address for unauthenticated requests
  • Limit by user/API key for authenticated requests
  • Consider query complexity in rate calculations

Disable Introspection in Production

Projects SHOULD disable introspection in production environments:

Why: Introspection exposes your entire schema, making it easier for attackers to find vulnerabilities. Provide schema documentation through other means.

Input Validation

Projects MUST validate all input data:

input CreateUserInput {
  email: String!      # Validate: email format
  password: String!   # Validate: length only (see Password Inputs)
  name: String!       # Validate: max length, no scripts
}

Validate at the GraphQL layer before business logic.

Password Inputs

Projects MUST NOT impose character-composition rules on password inputs: no required mixture of upper case, lower case, digits, or symbols. Instead:

  • Projects MUST enforce a minimum length. NIST SP 800-63B-43 requires 15 characters for a password used as a single authentication factor and 8 characters for one used as a factor in multi-factor authentication.
  • Projects SHOULD permit a maximum length of at least 64 characters and accept every printing ASCII character, the space character, and Unicode.
  • Projects MUST reject passwords that appear on a blocklist of common, expected, or compromised values.
  • Projects MUST rate-limit failed authentication attempts.

Why: Composition rules produce predictable substitutions such as P@ssw0rd1, which add almost no guessing resistance while pushing people towards reuse and short passwords. NIST SP 800-63B-4 §3.1.1.2 states that verifiers “SHALL NOT impose other composition rules (e.g., requiring mixtures of different character types) for passwords”; length, blocklists, and throttling carry the security weight instead. Those guidelines govern digital identity services, and this guide adopts them as the baseline for any GraphQL API that accepts passwords.

# Good: length is the only schema-level password constraint
input CreateUserInput {
  email: String!      # Validate: email format
  password: String!   # Validate: >= 15 chars, accept >= 64
  name: String!       # Validate: max length, no scripts
}

# Bad
input CreateUserInput {
  email: String!
  password: String!   # Validate: 8-20 chars, 1 upper, 1 digit, 1 symbol
  name: String!
}

Enforce the blocklist and the failure rate limit in the authentication service, not in GraphQL input coercion: both need I/O and per-account state that scalar coercion cannot reach, and coercion errors are returned to unauthenticated callers before any resolver runs.

Authentication and Authorization

Projects MUST implement authentication at the transport layer and authorization at the field level:

type Query {
  # Public
  publicPosts: [Post!]!

  # Requires authentication
  me: User! @authenticated

  # Requires specific permission
  adminUsers: [User!]! @hasRole(role: ADMIN)
}

type User {
  id: ID!
  email: String!

  # Only visible to user themselves or admins
  ssn: String @isOwnerOrAdmin
}

Performance

N+1 Query Problem

Projects MUST use DataLoaders (or equivalent batching) to prevent N+1 queries:

# Without DataLoader: N+1 queries
query {
  users {       # 1 query
    orders {    # N queries (one per user)
      items {   # N*M queries
        product { }
      }
    }
  }
}

DataLoaders batch multiple loads into single queries and cache results within a request.

Field-Level Caching

Projects MAY implement field-level caching for expensive computations:

type User {
  id: ID!
  # Cached for 5 minutes
  orderCount: Int! @cacheControl(maxAge: 300)

  # Never cached (real-time)
  currentBalance: Float! @cacheControl(maxAge: 0)
}

Persisted Queries

Projects SHOULD use persisted queries in production:

Benefits:

  • Smaller request payloads (send hash instead of full query)
  • Prevents arbitrary queries from untrusted clients
  • Enables query whitelisting
# Instead of sending full query
POST /graphql
{
  "query": "query GetUser($id: ID!) { user(id: $id) { id name } }",
  "variables": { "id": "123" }
}

# Send query hash
POST /graphql
{
  "extensions": {
    "persistedQuery": {
      "sha256Hash": "abc123..."
    }
  },
  "variables": { "id": "123" }
}

Schema Evolution

Deprecation

Projects MUST deprecate fields before removal:

type User {
  id: ID!
  name: String! @deprecated(reason: "Use firstName and lastName")
  firstName: String!
  lastName: String!
}

Process:

  1. Add new field alongside old field
  2. Mark old field as deprecated with reason
  3. Monitor usage of deprecated field
  4. Remove after deprecation period (e.g., 6 months)

Additive Changes

Projects SHOULD prefer additive changes over breaking changes:

Safe changes (additive):

  • Adding new types
  • Adding new fields to existing types
  • Adding optional arguments to fields
  • Adding new enum values

Breaking changes (avoid):

  • Removing types or fields
  • Changing field types
  • Making nullable fields non-nullable
  • Removing enum values

Versioning

Projects SHOULD NOT version GraphQL APIs. Instead:

  • Use deprecation for field evolution
  • Use feature flags for major changes
  • Maintain backwards compatibility

Why: GraphQL’s schema introspection and deprecation system provides built-in evolution mechanisms. URL versioning (/v1/, /v2/) fragments your API.

Subscriptions

When to Use Subscriptions

Projects SHOULD use subscriptions for:

  • Real-time notifications
  • Live data feeds
  • Collaborative features

Projects SHOULD NOT use subscriptions for:

  • Data that changes infrequently
  • One-time requests (use queries)
  • Large data transfers (use pagination)

Subscription Design

type Subscription {
  # Filter by ID
  orderStatusChanged(orderId: ID!): Order!

  # Filter by type
  notificationReceived(types: [NotificationType!]): Notification!
}

enum NotificationType {
  MESSAGE
  MENTION
  SYSTEM
}

Why: Allow clients to filter subscriptions to relevant events, reducing unnecessary data transfer.

Documentation

Schema Documentation

Projects MUST document types and fields:

"""
A user in the system.
"""
type User {
  """
  Unique identifier for the user.
  """
  id: ID!

  """
  User's email address. Used for authentication and notifications.
  Must be unique across all users.
  """
  email: String!

  """
  User's display name. Maximum 100 characters.
  """
  name: String!
}

Argument Documentation

type Query {
  """
  Search for users matching the given criteria.
  Returns paginated results using cursor-based pagination.
  """
  users(
    """
    Filter users by status, date range, or search term.
    """
    filter: UserFilter

    """
    Maximum number of users to return. Default: 20, Max: 100.
    """
    first: Int = 20

    """
    Cursor for forward pagination.
    """
    after: String
  ): UserConnection!
}

Testing

Schema Testing

Projects MUST test schema validity:

def test_schema_is_valid():
    # Schema should compile without errors
    assert schema is not None

def test_schema_has_required_types():
    type_names = [t.name for t in schema.type_map.values()]
    assert "User" in type_names
    assert "Query" in type_names
    assert "Mutation" in type_names

Resolver Testing

Projects MUST test resolvers independently:

async def test_create_user_resolver():
    result = await schema.execute(
        """
        mutation CreateUser($input: CreateUserInput!) {
          createUser(input: $input) {
            user { id email }
            errors { field message }
          }
        }
        """,
        variable_values={
            "input": {
                "email": "test@example.com",
                "password": "example-fixture-passphrase-01",
                "name": "Test"
            }
        },
        context_value=context,
    )
    assert result.errors is None
    assert result.data["createUser"]["user"]["email"] == "test@example.com"

Integration Testing

Projects SHOULD test full query flows:

async def test_user_with_orders_flow(context):
    # Create user
    create_result = await create_user(
        email="flow@example.com",
        name="Flow Tester",
        context=context,
    )
    assert create_result.errors is None
    user_id = create_result.data["createUser"]["user"]["id"]

    # Create orders
    order_result = await create_order(
        user_id=user_id,
        items=[{"sku": "WIDGET-1", "quantity": 2}],
        context=context,
    )
    assert order_result.errors is None

    # Query user with orders
    query_result = await schema.execute(
        """
        query GetUserWithOrders($id: ID!) {
          user(id: $id) {
            orders {
              id
              status
            }
          }
        }
        """,
        variable_values={"id": user_id},
        context_value=context,
    )
    assert query_result.errors is None
    assert len(query_result.data["user"]["orders"]) > 0

create_user and create_order are test helpers that wrap schema.execute. The context fixture supplies one request context to every execution, so each step observes the state written by the previous one. Assert errors is None before dereferencing data: a failed execution leaves data as None or with None in the failed field, and the resulting TypeError hides the real GraphQL error.

See Also

References

Footnotes

  1. GraphQL Specification - Official GraphQL specification ↩

  2. Relay Connection Specification - Cursor-based pagination standard ↩

  3. NIST SP 800-63B-4 Sec. 3.1.1 - Password requirements ↩