gqlgen vs graphql-go

Which GraphQL Library to Use

Web
Use **gqlgen** for new Go projects because it is the industry standard, actively maintained, and generates type-safe code that integrates seamlessly with Go's tooling.

The choice between generated and manual

You are building a GraphQL API in Go. You open a terminal, run a search, and find two libraries. One generates Go code from your schema. The other asks you to wire everything up by hand using reflection. You need to pick one before you write your first resolver. The choice shapes how you debug, how you refactor, and how your team handles schema changes six months from now.

Code generation versus manual mapping is the core split. gqlgen reads your .graphql files and your Go struct definitions, then writes Go code that bridges the two. graphql-go relies on runtime reflection to match Go fields to GraphQL fields. Think of gqlgen like a compiler that catches type mismatches before your server starts. Think of graphql-go like a runtime interpreter that checks types while requests are flying in. The generated approach shifts errors to compile time. The reflection approach pushes them to production. Code generation also gives you autocomplete in your editor. You type r.db. and the IDE suggests methods. Manual mapping leaves you guessing field names until the server crashes.

Generated code is not a black box. It is a contract enforcer. Trust the compiler to catch drift.

How code generation actually works

gqlgen parses the GraphQL schema into an abstract syntax tree. It scans your Go package for structs tagged with json or graphql. It matches field names and types. When it finds a match, it writes a resolver interface and a type binding. The generated code calls your resolver, marshals the return value, and handles nullability according to the schema. If your resolver returns nil for a non-nullable field, the generated wrapper catches it and returns a GraphQL error instead of panicking.

graphql-go takes a different path. You define a schema in Go using reflection helpers. You pass a map of resolvers. The library inspects your structs at runtime, reads field tags, and builds the execution plan on the fly. It works, but the type checking happens during request execution. A mismatch between your schema and your Go code surfaces as a runtime panic or a missing field in the response. You only find out when a client hits the endpoint. The reflection overhead also adds latency. Every request pays the cost of field discovery. Code generation pays the cost once, at build time.

Reflection is flexible. It is also slow and opaque. Generated code is rigid. It is fast and explicit. Pick the tradeoff that matches your deployment environment.

Minimal setup

Start with a schema. Write it in schema.graphql.

type Query {
    user(id: ID!): User
}

type User {
    id: ID!
    name: String!
    email: String!
}

Define the Go struct that will carry the data.

// models.go
// User represents a single account in the system.
type User struct {
    // JSON tags align Go fields with GraphQL field names.
    ID    string `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

Run the generator.

gqlgen generate

The tool writes a generated.go file and a resolver.go stub. You fill in the stub.

// resolvers.go
// User fetches a single user by their identifier.
func (r *queryResolver) User(ctx context.Context, id string) (*User, error) {
    // Return a placeholder for now. Replace with a database lookup.
    // The signature matches the generated interface exactly.
    return &User{ID: id, Name: "Alice", Email: "alice@example.com"}, nil
}

The compiler checks the signature. If the schema says User must return email: String! but your Go struct drops the field, gqlgen refuses to generate the code. You fix it before you run the server. Keep your schema and structs in sync. The generator will tell you when they drift.

What happens at runtime

When a client sends a query, gqlgen routes it through the generated execution engine. The engine parses the query, validates it against the schema, and walks the field tree. For each leaf field, it calls the corresponding resolver method. The generated wrapper handles argument parsing, context propagation, and error wrapping. If a resolver returns an error, the engine attaches it to the response under the errors key and continues executing sibling fields. This matches the GraphQL specification exactly.

graphql-go builds the execution plan at runtime. It uses reflection to find methods on your resolver struct. It matches method names to field names. If a method is missing, it falls back to a default resolver or returns null. The fallback behavior masks mistakes. You might deploy a resolver that never gets called because the method name does not match the schema exactly. You only notice when the client receives unexpected null values.

The execution model also affects debugging. gqlgen generates stack traces that point to your resolver code. graphql-go stack traces point into the reflection package. You spend more time reading library internals than fixing your business logic. Stick to the tool that keeps your code visible.

Realistic resolver pattern

Real APIs need context, error handling, and database calls. gqlgen fits naturally into Go's standard patterns.

// resolvers.go
// User fetches a user from the database and handles cancellation.
func (r *queryResolver) User(ctx context.Context, id string) (*User, error) {
    // Context carries deadlines and cancellation signals.
    // Always pass ctx to database drivers and child goroutines.
    user, err := r.db.GetUser(ctx, id)
    if err != nil {
        // Wrap the error. gqlgen converts it to a GraphQL error response.
        // The wrapper preserves the original error for logging.
        return nil, fmt.Errorf("fetch user: %w", err)
    }
    return user, nil
}

The context.Context parameter is mandatory in gqlgen resolvers. It flows through every layer. If a client cancels the request, the context fires, the database driver aborts the query, and the goroutine exits cleanly. graphql-go does not enforce this pattern. You have to wire context manually, and it is easy to forget it in nested resolvers.

Convention matters here. Go code expects gofmt to handle indentation and layout. Run it on save. Do not fight the formatter. Error handling follows the same rule. Write if err != nil { return nil, err }. The verbosity is intentional. It makes the failure path visible. gqlgen leans into these conventions. The generated code passes context.Context as the first argument, names it ctx, and returns (T, error). It reads like idiomatic Go.

Receiver naming follows a simple rule. Use one or two letters that match the type. (r *queryResolver) is standard. (this *queryResolver) or (self *queryResolver) breaks community expectations. Keep names short. They appear on every line of the file.

Trust the conventions. They exist to keep large codebases readable.

Pitfalls and compiler feedback

Type mismatches are the most common trap. If your schema declares email: String! but your Go struct has Email *string, gqlgen stops with type mismatch: expected string, got *string. You fix the struct or adjust the schema. The compiler error is explicit. With graphql-go, the same mismatch might silently drop the field or panic with reflect: call of reflect.Value.Interface on zero Value. You chase it through logs instead of fixing it in your editor.

Another trap is forgetting to implement the generated interface. gqlgen generates a UserResolver interface. If you skip a method, the compiler rejects the program with *userResolver does not implement graphql.UserResolver (missing Email method). You cannot start the server until the contract is complete. Manual libraries skip this safety net. You might deploy a resolver that never gets called because the field name does not match the schema exactly.

A third trap is ignoring the underscore. When a resolver returns multiple values but you only need one, use _ to discard the rest. result, _ := r.db.Fetch(ctx). The underscore tells the compiler you considered the second return value and chose to drop it. Dropping errors without acknowledging them is a fast track to silent failures. Always handle the error or discard it intentionally.

Interface design follows a simple mantra. Accept interfaces, return structs. Your resolver methods should return concrete *User values. Your database layer should accept sql.DB or a custom Querier interface. This keeps dependencies loose and testing straightforward. The generated code expects concrete types for the response. It handles the conversion automatically.

The worst goroutine bug is the one that never logs. Always attach request IDs to your context. Print them in your error handlers. You will thank yourself when a production query hangs.

Decision matrix

Pick the tool that matches your project size and team workflow.

Use gqlgen when you want compile-time guarantees that your schema and Go code match. Use gqlgen when your team needs automatic resolver stubs and type-safe bindings. Use gqlgen when you plan to scale the API with subscriptions, complex mutations, or multiple services. Use graphql-go when you are writing a one-off script that never leaves your local machine. Use graphql-go when you need to inspect and modify the schema at runtime without regenerating code. Use plain HTTP handlers when you do not need GraphQL at all and a REST endpoint solves the problem faster.

Generated code is not a black box. It is a contract enforcer. Trust the compiler to catch drift.

Where to go next