Skip to main content

GraphQL APIs with Go and gqlgen

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 6 min read

GraphQL lets API clients request exactly the fields they need, reducing over-fetching and enabling clients to aggregate data from multiple resources in a single request. In Go, gqlgen is the standard library — it generates type-safe resolver boilerplate from your schema, leaving you to implement the actual data fetching.

The key difference from REST: a GraphQL API has a single endpoint (/graphql), and the schema is the contract. Add a field to the schema and client tooling immediately knows it exists.

For REST comparison see Go REST vs gRPC and for REST implementation see Go building REST APIs.

Setup with gqlgen

go get github.com/99designs/gqlgen
go run github.com/99designs/gqlgen init

gqlgen init creates a schema.graphqls file and generates boilerplate. The workflow is schema-first: edit the schema, regenerate code, implement the generated interfaces.

Defining the Schema

# graph/schema.graphqls
type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
  createdAt: String!
}

type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  published: Boolean!
}

type Query {
  user(id: ID!): User
  users(page: Int, perPage: Int): UserPage!
  post(id: ID!): Post
}

type UserPage {
  users: [User!]!
  total: Int!
  page: Int!
  perPage: Int!
}

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

input CreateUserInput {
  name: String!
  email: String!
}

input UpdateUserInput {
  name: String
  email: String
}

input CreatePostInput {
  title: String!
  content: String!
  authorID: ID!
}

type Subscription {
  postCreated: Post!
}

After editing the schema, regenerate:

go run github.com/99designs/gqlgen generate

This regenerates graph/generated.go (resolver interfaces) and updates graph/model/models_gen.go (Go structs from schema types). You only implement the interfaces in graph/resolver.go.

Implementing Resolvers

gqlgen generates a Resolver interface for each type with fields that need custom resolution. You implement those interfaces:

// graph/resolver.go
package graph

type Resolver struct {
    UserRepo    UserRepository
    PostRepo    PostRepository
}

// graph/schema.resolvers.go — generated skeleton, you fill in the bodies
func (r *queryResolver) User(ctx context.Context, id string) (*model.User, error) {
    user, err := r.UserRepo.GetByID(ctx, id)
    if errors.Is(err, ErrNotFound) {
        return nil, nil  // GraphQL returns null for missing optional objects
    }
    return user, err
}

func (r *queryResolver) Users(ctx context.Context, page *int, perPage *int) (*model.UserPage, error) {
    p, pp := 1, 20
    if page != nil { p = *page }
    if perPage != nil { pp = min(*perPage, 100) }

    users, total, err := r.UserRepo.List(ctx, p, pp)
    if err != nil {
        return nil, err
    }
    return &model.UserPage{
        Users:   users,
        Total:   total,
        Page:    p,
        PerPage: pp,
    }, nil
}

func (r *mutationResolver) CreateUser(ctx context.Context, input model.CreateUserInput) (*model.User, error) {
    // Validate
    if strings.TrimSpace(input.Name) == "" {
        return nil, fmt.Errorf("name is required")
    }
    if !strings.Contains(input.Email, "@") {
        return nil, fmt.Errorf("invalid email")
    }
    return r.UserRepo.Create(ctx, input.Name, input.Email)
}

Field Resolvers for N+1 Prevention

The classic N+1 problem in GraphQL: a query for 20 users with their posts triggers 1 user query + 20 post queries. gqlgen’s field resolvers are where you hook in a DataLoader:

// graph/schema.resolvers.go
func (r *userResolver) Posts(ctx context.Context, obj *model.User) ([]*model.Post, error) {
    // Without DataLoader: 1 DB query per user = N+1 problem
    // return r.PostRepo.GetByUserID(ctx, obj.ID)

    // With DataLoader: batched, 1 query for all users in this request
    return PostLoaderFromContext(ctx).Load(obj.ID)
}

DataLoader batches all Posts field resolution calls within one request into a single database query. Install with:

go get github.com/graph-gophers/dataloader/v7
type PostLoader struct {
    loader *dataloader.Loader[string, []*model.Post]
}

func NewPostLoader(repo PostRepository) *PostLoader {
    batchFn := func(ctx context.Context, keys dataloader.Keys[string]) []*dataloader.Result[[]*model.Post] {
        // Called once with all user IDs needed in this request
        ids := keys.Keys()
        postsByUser, err := repo.GetByUserIDs(ctx, ids)

        results := make([]*dataloader.Result[[]*model.Post], len(ids))
        for i, id := range ids {
            if err != nil {
                results[i] = &dataloader.Result[[]*model.Post]{Error: err}
            } else {
                results[i] = &dataloader.Result[[]*model.Post]{Data: postsByUser[id]}
            }
        }
        return results
    }

    return &PostLoader{loader: dataloader.NewBatchedLoader(batchFn)}
}

func (l *PostLoader) Load(userID string) ([]*model.Post, error) {
    thunk := l.loader.Load(context.Background(), userID)
    return thunk()
}

Inject the DataLoader per request via context — it must be request-scoped, not shared across requests.

Middleware: Authentication

// Add middleware to the GraphQL server
srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: &graph.Resolver{...}}))

// Authentication middleware
authMiddleware := func(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if strings.HasPrefix(token, "Bearer ") {
            claims, err := parseJWT(strings.TrimPrefix(token, "Bearer "))
            if err == nil {
                ctx := context.WithValue(r.Context(), userCtxKey, claims)
                r = r.WithContext(ctx)
            }
        }
        next.ServeHTTP(w, r)
    })
}

http.Handle("/graphql", authMiddleware(srv))

// In resolvers — check authentication
func currentUser(ctx context.Context) (*Claims, error) {
    claims, ok := ctx.Value(userCtxKey).(*Claims)
    if !ok {
        return nil, fmt.Errorf("authentication required")
    }
    return claims, nil
}

func (r *mutationResolver) DeleteUser(ctx context.Context, id string) (bool, error) {
    caller, err := currentUser(ctx)
    if err != nil {
        return false, err
    }
    if caller.Role != "admin" && caller.UserID != id {
        return false, fmt.Errorf("forbidden")
    }
    return r.UserRepo.Delete(ctx, id)
}

Subscriptions

GraphQL subscriptions deliver real-time updates via WebSocket. gqlgen handles the WebSocket upgrade; you implement the event source:

func (r *subscriptionResolver) PostCreated(ctx context.Context) (<-chan *model.Post, error) {
    ch := make(chan *model.Post, 10)

    // Subscribe to your event bus, message queue, or DB notifications
    sub, err := r.EventBus.Subscribe(ctx, "post.created")
    if err != nil {
        return nil, err
    }

    go func() {
        defer close(ch)
        defer sub.Unsubscribe()
        for {
            select {
            case event := <-sub.Events():
                post := event.Payload.(*model.Post)
                select {
                case ch <- post:
                case <-ctx.Done():
                    return
                }
            case <-ctx.Done():
                return
            }
        }
    }()
    return ch, nil
}

The client subscribes via WebSocket. gqlgen’s handler.NewDefaultServer includes WebSocket support automatically.

Error Handling

GraphQL returns partial data on errors — some fields can succeed while others fail. Return nil, err from a resolver to indicate that field failed:

func (r *queryResolver) User(ctx context.Context, id string) (*model.User, error) {
    user, err := r.UserRepo.GetByID(ctx, id)
    if errors.Is(err, ErrNotFound) {
        return nil, nil  // field is nullable — return null, no error
    }
    if err != nil {
        // gqlgen wraps this in a GraphQL error object
        return nil, fmt.Errorf("loading user: %w", err)
    }
    return user, nil
}

For user-visible error details (validation, authorization), use gqlgen’s gqlerror package to add extensions:

import "github.com/vektah/gqlparser/v2/gqlerror"

func (r *mutationResolver) CreateUser(ctx context.Context, input model.CreateUserInput) (*model.User, error) {
    if input.Email == "" {
        return nil, &gqlerror.Error{
            Message: "email is required",
            Extensions: map[string]interface{}{
                "code":  "VALIDATION_ERROR",
                "field": "email",
            },
        }
    }
    // ...
}

Summary

  • gqlgen is schema-first: edit .graphqls, run go generate, implement the generated interfaces
  • Field resolvers for nested types (User.Posts) are where N+1 queries happen — use DataLoader to batch them
  • DataLoader must be request-scoped; inject via context in middleware
  • Authentication middleware runs before the resolver; pull Claims from context inside resolvers
  • GraphQL subscriptions use WebSocket; gqlgen handles the protocol, you provide the event channel
  • Return nil, nil for missing optional objects; use gqlerror.Error with extensions for user-visible validation errors

Resources

Comments

👍 Was this article helpful?