The dashboard problem
You are building a dashboard for a project management tool. The frontend needs a list of tasks. You write a /tasks endpoint. The frontend also needs the assignee name for each task. You add a /tasks/assignees endpoint. Then the designer wants the priority color and the due date. You update the endpoint. Suddenly the mobile app asks for the same data but without the description field. You start writing flags like ?include=assignee,priority and the response shape becomes a mess.
GraphQL solves this by flipping the script. The client sends a query describing exactly what it needs. The server returns that shape and nothing else. One endpoint handles all requests. In Go, the standard way to build this is with gqlgen, a code generator that turns your schema into a type-safe API.
Schema as a contract
Think of a REST API as a buffet with fixed plates. You order the burger, you get fries and a drink whether you want them or not. GraphQL is a made-to-order kitchen. You hand the chef a form listing "burger, no fries, extra pickles." The kitchen sends exactly that.
The form is the schema. It defines every type, every field, and every query your API supports. gqlgen reads this schema and generates Go code that enforces it. If your Go code tries to return a string where the schema expects an integer, the compiler rejects the build. This code generation step is the core of the approach. It gives you compile-time safety across the boundary between your API definition and your application logic.
Minimal example
Here is the skeleton of a GraphQL server. You define the schema, run the generator, implement the resolvers, and start the HTTP server.
First, initialize the module and install the generator.
# Create module and install gqlgen
go mod init example.com/graphql-demo
go get github.com/99designs/gqlgen
Create the schema file. This declares a Query type with a hello field that returns a string.
# schema.graphql
type Query {
hello: String!
}
Run the generator. This creates the gqlgen.yml config and the initial Go files.
# Generate the initial code structure
go run github.com/99designs/gqlgen init
The generator creates resolver.go with a stub. Fill in the logic.
// resolver.go
package main
import (
"context"
)
// Resolver is the root resolver struct
type Resolver struct{}
// QueryResolver implements the Query type
type QueryResolver struct{ *Resolver }
// Hello returns a greeting for the hello query
func (r *QueryResolver) Hello(ctx context.Context) (string, error) {
// Context carries cancellation and deadlines
// Return the value and nil error
return "Hello, GraphQL!", nil
}
Wire the server in main.go. The generated NewExecutableSchema connects the schema to your resolvers.
// main.go
package main
import (
"log"
"net/http"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
)
func main() {
// NewExecutableSchema creates the runtime schema from generated code
// Config passes your resolver implementation to the generator
srv := handler.New(NewExecutableSchema(Config{Resolvers: &Resolver{}}))
// Playground provides a web UI for testing queries
srv.AddTransport(playground.Handler("GraphQL Playground", "/playground"))
// http.NewTransport handles the actual GraphQL HTTP requests
srv.AddTransport(http.NewTransport())
// Mount the handler at /graphql
http.Handle("/graphql", srv)
log.Println("Server running at :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Run the server and open http://localhost:8080/playground. Send the query { hello } and you get { "data": { "hello": "Hello, GraphQL!" } }.
Schema first, code second. The generator does the heavy lifting.
How the generator works
When you run go generate, gqlgen parses your .graphql files and writes Go source code. It creates a struct for every object type in the schema. It creates a function signature for every field. It builds an executableSchema that maps field names to resolver functions.
The generated code lives in generated.go. You should never edit this file by hand. It is overwritten every time you run the generator. Your job is to implement the interfaces in resolver.go.
The compiler checks your implementation. If you change the schema to return an Int instead of a String, the next build fails with cannot use "Hello" (untyped string constant) as int value in return argument. This is the safety net. The schema is the source of truth, and the generator ensures your Go code matches it.
gqlgen also handles type mapping. By default, it maps GraphQL scalars to Go primitives. String becomes string, Int becomes int, Boolean becomes bool. You can customize this in gqlgen.yml if you need to map a GraphQL type to a specific Go struct.
Code generation is a feature, not a bug. Let the compiler enforce the schema.
Realistic example
A real API fetches data and handles arguments. Here is a query that accepts a limit and returns a list of users.
Update the schema to include a User type and a users query with an argument.
# schema.graphql
type User {
id: ID!
name: String!
email: String
}
type Query {
users(limit: Int): [User!]!
}
Run the generator again to update the types and signatures.
# Regenerate after schema changes
go generate ./...
Implement the resolver. Use a mock database for this example.
// resolver.go
package main
import (
"context"
)
// User represents a user in the system
type User struct {
ID string
Name string
Email string
}
// Resolver holds dependencies like database connections
type Resolver struct {
// In a real app, you'd inject a DB client here
}
// QueryResolver implements the Query type
type QueryResolver struct{ *Resolver }
// Users returns a list of users with an optional limit
func (r *QueryResolver) Users(ctx context.Context, limit *int) ([]User, error) {
// Context must be passed to all downstream calls
// Check for cancellation before doing work
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
// Mock data source
allUsers := []User{
{ID: "1", Name: "Alice", Email: "alice@example.com"},
{ID: "2", Name: "Bob", Email: "bob@example.com"},
{ID: "3", Name: "Charlie", Email: "charlie@example.com"},
}
// Apply limit if provided
if limit != nil {
if *limit < len(allUsers) {
allUsers = allUsers[:*limit]
}
}
return allUsers, nil
}
The limit argument is a pointer because it is optional. If the client omits it, limit is nil. The resolver checks this and applies the slice logic only when the value exists.
Context is plumbing. Run it through every long-lived call site.
Pitfalls and errors
GraphQL in Go introduces a few specific failure modes.
If you change the schema and forget to run the generator, the build fails with undefined: generated type errors. The generated code is the bridge. If it is stale, the compiler cannot find the types your resolvers depend on. Always run go generate after editing .graphql files.
Resolvers run concurrently. If a query requests multiple fields, gqlgen may execute their resolvers in parallel. This is efficient but dangerous if you share mutable state. Never store request-scoped data in the Resolver struct fields. Pass data through the context or return it from functions.
The N+1 query problem is common. If a query requests a list of users and each user's posts, a naive implementation fetches all users, then loops through them to fetch posts. This generates one query for users and N queries for posts. The solution is a data loader pattern that batches requests. Libraries like github.com/graph-gophers/dataloader help with this.
Panics in resolvers are caught by gqlgen and returned as GraphQL errors. This prevents the server from crashing, but you should handle errors explicitly. Return an error from the resolver function instead of panicking. The error appears in the errors array of the response, attached to the specific field.
If you return a type that does not match the schema, the compiler rejects it with cannot use x as T value in return argument. This is a good thing. It catches mismatches before runtime.
The worst goroutine bug is the one that never logs. Always check ctx.Done() in long-running resolvers.
Decision matrix
GraphQL is not the right tool for every API. Choose based on your needs.
Use GraphQL when clients need flexible data shapes and you want to reduce over-fetching. Use GraphQL when multiple clients share the same backend and have different requirements. Use GraphQL when you want a single endpoint to aggregate data from multiple services.
Use REST when your API is simple, cacheable, and consumed by external partners who prefer standard HTTP semantics. Use REST when you need strict versioning and clear resource boundaries. Use REST when caching at the CDN level is a priority.
Use gRPC when you need high-performance internal communication between microservices. Use gRPC when you want strict contract enforcement and automatic client code generation for multiple languages. Use gRPC when streaming data is a core requirement.
Use a GraphQL gateway when you have multiple backend services and want to aggregate them into a single schema for the client. The gateway translates client queries into calls to downstream services.
Pick the tool that matches your data access pattern. Do not force GraphQL onto a simple CRUD API.