The blueprint that writes your code
Your frontend engineer sends a request. They need a user's name and their latest post. Your REST endpoint returns the user, the post, the comments, the avatars, and the server logs. The frontend throws away 80% of the payload. Or worse, the frontend makes three separate requests to stitch the data together. The page loads slowly. The database groans.
GraphQL exists to solve this. The client asks for exactly what it needs. The server delivers exactly that. In Go, gqlgen is the standard way to build a GraphQL server. It doesn't use reflection to guess your types. It generates Go code from your schema. You get compile-time safety for your API contract.
How gqlgen works
Most GraphQL libraries in other languages work at runtime. You define a schema, you write functions, and the library uses reflection to match the schema fields to your functions. That approach is flexible but fragile. A typo in a field name causes a panic in production. A type mismatch crashes the query.
gqlgen takes a different path. It treats the schema as a source of truth and generates Go code from it. The tool reads your GraphQL schema definition, generates Go structs for your types, generates interfaces for your resolvers, and builds a router. You implement the generated interfaces. The Go compiler checks that your implementation matches the schema. If the schema changes, your code fails to compile until you update it. That failure is a feature. It catches contract violations before they reach users.
Think of the schema as a blueprint. gqlgen is the architect that builds the scaffolding. You fill in the rooms. The scaffolding ensures the rooms connect correctly.
A minimal server
Start with a fresh module. Install the generator tool. Create a schema file. Run the generator. Implement the resolver.
// go.mod
// module example.com/graphql-demo
// go 1.21
Create schema.graphqls. This file defines the API contract.
# schema.graphqls
# Defines the entry point for queries.
# The 'hello' field returns a string.
type Query {
hello: String
}
Create gqlgen.yml. This config tells the generator where to find the schema and where to put the code.
# gqlgen.yml
# Points to the schema file.
# Generates code in the 'generated' package.
schema:
- schema.graphqls
exec:
filename: generated.go
model:
filename: models_gen.go
resolver:
filename: resolver.go
type: Resolver
Run the generator. This command parses the schema and creates the Go files.
# output:
# generated.go
# models_gen.go
# resolver.go
go run github.com/99designs/gqlgen generate
Open resolver.go. The generator created an interface and a stub. Implement the method.
// resolver.go
// Package main implements the resolvers.
package main
import (
"context"
)
// Hello returns a greeting string.
// The signature matches the generated interface.
// Context is always the first parameter.
func (r *queryResolver) Hello(ctx context.Context) (string, error) {
// Return the value and nil error.
// The compiler enforces the return types.
return "world", nil
}
Wire it up in main.go. Use the generated handler.
// main.go
// Package main runs the server.
package main
import (
"log"
"net/http"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
)
func main() {
// Create the handler with the generated schema.
// The generated package contains the router.
srv := handler.NewDefaultServer(NewExecutableSchema(Config{Resolvers: &Resolver{}}))
// Mount the GraphQL endpoint.
http.Handle("/query", srv)
// Mount the playground for testing.
http.Handle("/", playground.Handler("GraphQL", "/query"))
// Start the server.
log.Println("Go to http://localhost:8080/")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Run the server. Open the playground. Execute the query.
# Query in playground
query {
hello
}
The response returns {"data": {"hello": "world"}}. The generator handled parsing, validation, and marshaling. You wrote one function.
Goroutines are cheap. Channels are not magic. Generated code is a servant, not a master.
What happens under the hood
When you run the generator, gqlgen parses the schema and produces three key artifacts.
generated.go contains the executable schema. It holds the router, the type registry, and the logic to dispatch queries to resolvers. This file is large. You never edit it manually.
models_gen.go contains Go structs for your GraphQL types. If you define a User type in the schema, the generator creates a User struct. You can use this struct in your resolvers or map your database models to it.
resolver.go contains interfaces for your resolvers. The generator creates a QueryResolver interface with a method for each field in the Query type. You implement this interface. The compiler ensures your methods have the correct signature.
At runtime, the flow is straightforward. The HTTP handler receives a JSON request. The generated router parses the query string. It validates the query against the schema. It builds an execution plan. It calls your resolver methods. It collects the results and marshals them back to JSON.
If your resolver returns an error, gqlgen captures it and includes it in the errors array of the response. The query doesn't crash. The client gets partial data and the error message. This behavior matches the GraphQL spec.
Trust gofmt. Argue logic, not formatting.
A realistic example
Real APIs have types, relationships, and errors. Define a User and a Post. Add a relationship. Handle errors.
# schema.graphqls
# Defines the User type with an ID and name.
# The 'posts' field returns a list of Post objects.
type User {
id: ID!
name: String!
posts: [Post!]!
}
# Defines the Post type.
type Post {
id: ID!
title: String!
}
# Entry point.
type Query {
user(id: ID!): User
}
Run the generator again. The tool updates the interfaces and models.
# output:
# generated.go
# models_gen.go
# resolver.go
go run github.com/99designs/gqlgen generate
Implement the resolvers. Use a mock database. Handle missing users.
// resolver.go
// Package main implements the resolvers.
package main
import (
"context"
"errors"
)
// User returns a user by ID.
// Returns an error if the user is not found.
func (r *queryResolver) User(ctx context.Context, id string) (*User, error) {
// Simulate database lookup.
// In real code, use a repository pattern.
user := findUser(id)
if user == nil {
// Return a typed error.
// gqlgen includes this in the response errors.
return nil, errors.New("user not found")
}
return user, nil
}
// Posts returns the posts for a user.
// The receiver is the User object from the parent resolver.
func (r *userResolver) Posts(ctx context.Context, obj *User) ([]*Post, error) {
// Simulate fetching posts.
// The context propagates cancellation and deadlines.
posts := findPostsByUser(obj.ID)
return posts, nil
}
Define the models. The generator created stubs. Fill them in.
// models.go
// Package main defines the domain models.
package main
// User represents a user in the system.
// The ID and Name fields match the schema.
type User struct {
ID string `json:"id"`
Name string `json:"name"`
}
// Post represents a post.
type Post struct {
ID string `json:"id"`
Title string `json:"title"`
}
Add mock functions.
// resolver.go
// findUser simulates a database query.
func findUser(id string) *User {
if id == "1" {
return &User{ID: "1", Name: "Alice"}
}
return nil
}
// findPostsByUser simulates a database query.
func findPostsByUser(userID string) []*Post {
return []*Post{
{ID: "101", Title: "Hello World"},
{ID: "102", Title: "Go Tips"},
}
}
Query the API.
# Query in playground
query {
user(id: "1") {
id
name
posts {
title
}
}
}
The response returns the user and their posts. The generator called User, then called Posts on the result. The context flowed through both calls.
Context is plumbing. Run it through every long-lived call site.
Pitfalls and errors
gqlgen is powerful, but it has traps.
The N+1 problem is the most common performance bug. If you query a list of users, and each user resolver queries the database for posts, you get one query for users and N queries for posts. The database slows down. Use a data loader pattern to batch requests. Libraries like github.com/graph-gophers/dataloader help.
Generated code drift happens when you edit the schema but forget to regenerate. The compiler catches this. You get an error like cannot use resolver (type *MyResolver) as type generated.Resolver. Run the generator to fix it.
Never edit generated.go. The generator overwrites it. If you need custom logic, add it to your resolver or model files.
Circular dependencies in the schema can cause issues. If User references Post and Post references User, the generator might struggle. Break the cycle with interfaces or separate types.
The compiler rejects type mismatches. If the schema says String and your resolver returns int, you get cannot use value (type int) as type string in return argument. Fix the resolver or the schema.
The worst goroutine bug is the one that never logs.
When to use gqlgen
Use gqlgen when you want type safety and code generation. It generates Go code from your schema. The compiler checks your resolvers. You get fast execution and early error detection.
Use graphql-go when you want runtime reflection and no code generation. It matches schema fields to methods using reflection. It's flexible but slower and less safe.
Use REST when your API is simple and clients don't need flexible queries. REST is easier to cache and debug. GraphQL adds complexity for nested data and flexible fetching.
Use a data loader when you have N+1 query problems. Batch requests to the database. Reduce latency.
Don't fight the type system. Wrap the value or change the design.