How to Define a GraphQL Schema in Go

Web
Define GraphQL schemas in Go by using the gqlgen library to generate code from .graphql files.

The blueprint and the factory

You want an API where the client decides exactly which fields to fetch. You do not want to over-fetch user profiles or under-fetch order details. GraphQL promises that flexibility. Go does not ship with a GraphQL engine. You have to bring your own tooling to bridge the gap between a declarative schema and Go strict type system.

A GraphQL schema is a contract. It tells the server what data exists, how it relates, and what operations are allowed. In many languages, you define that contract directly in code. In Go, the community prefers a code-generation approach. You write the contract in a dedicated .graphql file. A tool reads that file and generates Go interfaces, structs, and routing logic. Think of it like ordering a custom engine. You provide the specifications on a blueprint. The factory machines stamp out the metal parts. You only have to wire them together.

The schema is the source of truth. Generate the code, implement the resolvers, and let the compiler enforce the contract.

Setting up the generator

The standard tool for this job is gqlgen. It reads your schema and produces a type-safe server. Install it and scaffold a project:

go install github.com/99designs/gqlgen@latest
gqlgen init

The command asks a few questions and drops a schema.graphql file into your directory. Here is the simplest possible schema:

type Query {
  hello: String!
}

Run the generator to translate that blueprint into Go code:

go run github.com/99designs/gqlgen generate

The tool creates a generated.go file and a resolver.go stub. The generated file contains the routing logic and type definitions. The resolver file contains the actual business logic. Here is what the resolver looks like after you implement it:

package main

import "context"

// Resolver handles the GraphQL operations defined in schema.graphql.
type Resolver struct{}

// Hello returns a greeting string.
func (r *Resolver) Hello(ctx context.Context) (string, error) {
  // Return the literal value. The ! in the schema means it cannot be null.
  return "world", nil
}

The generator scans schema.graphql and maps every type to a Go struct or interface. It creates a Resolver interface that matches your query names exactly. When you implement that interface in resolver.go, Go compile-time type checking guarantees your code matches the schema. If you change the schema, you run the generator again. The generated code updates. Your resolver code either compiles cleanly or the compiler points out the mismatch. This keeps the contract and the implementation in sync without manual boilerplate.

Go conventions apply here just like anywhere else. The receiver name is usually a short letter matching the type, like (r *Resolver). Functions that interact with external services or long-running operations should accept context.Context as the first parameter. The generator respects this convention and injects ctx into every resolver signature automatically.

Keep the generator running in a pre-commit hook or a Makefile target. Schema drift breaks builds faster than missing imports.

Mapping types and handling nulls

Real applications need more than a single string. You usually model entities, relationships, and input types. Here is a schema that defines a User type and a query to fetch one by ID:

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

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

The gqlgen.yml configuration file tells the generator how to map GraphQL types to Go types. By default, it looks for structs in your model package. Here is the corresponding Go struct:

package model

// User represents a registered account in the system.
type User struct {
  ID    string  `json:"id"`
  Name  string  `json:"name"`
  Email *string `json:"email,omitempty"`
}

Notice the pointer for Email. The schema marks it as nullable. Go zero value for a string is an empty string, which is not the same as null. Using a pointer lets the JSON encoder and the GraphQL server distinguish between no value and empty value.

Here is the resolver implementation that fetches the user:

package main

import (
  "context"
  "fmt"
)

// Resolver handles the GraphQL operations defined in schema.graphql.
type Resolver struct {
  // users simulates a database lookup.
  users map[string]*model.User
}

// User returns a single user by ID.
func (r *Resolver) User(ctx context.Context, id string) (*model.User, error) {
  // Look up the user in the in-memory map.
  u, ok := r.users[id]
  if !ok {
    // Return nil for the user. The schema allows null here.
    return nil, nil
  }
  // Return the pointer. The server will handle field resolution.
  return u, nil
}

The generator handles the heavy lifting. It parses the incoming JSON request, validates it against the schema, routes the fields to your resolver methods, and marshals the response. You only write the data access logic.

Map nullable fields to pointers. Map required fields to values. The compiler will catch the mismatch before the server starts.

How the server actually routes requests

A GraphQL endpoint is just an HTTP handler. You need to wire the generated schema into net/http. Here is a minimal server setup:

package main

import (
  "fmt"
  "log"
  "net/http"

  "github.com/99designs/gqlgen/graphql/handler"
  "github.com/99designs/gqlgen/graphql/playground"
)

func main() {
  // Create a new handler with the generated executable schema.
  srv := handler.New(NewExecutableSchema(Config{Resolvers: &Resolver{}}))

  // Mount the GraphQL endpoint at /query.
  http.Handle("/query", srv)
  // Mount the interactive playground at /graphql.
  http.Handle("/graphql", playground.Handler("GraphQL playground", "/query"))

  // Start listening on port 8080.
  log.Printf("connect to http://localhost:8080/graphql for the playground")
  log.Fatal(http.ListenAndServe(":8080", nil))
}

The handler.New function takes your generated schema and your resolver instance. It builds a request pipeline. When a client sends a POST request to /query, the handler unmarshals the JSON body, extracts the query and variables fields, and runs them through the validation engine. If the query is valid, the engine walks the abstract syntax tree and calls your resolver methods in order. Field resolution happens lazily. The server only calls the resolver for fields the client actually requested.

Convention aside: gofmt is mandatory. Do not argue about indentation or brace placement. Let the tool decide. Most editors run it on save. The generated code follows the same formatting rules, so your hand-written resolvers blend in seamlessly.

Trust the request pipeline. Validate input early, resolve fields lazily, and let the handler marshal the output.

Common friction points and compiler errors

The code-generation workflow introduces a few friction points. The most common is schema drift. You edit schema.graphql but forget to run the generator. The compiler rejects the program with cannot use resolver (variable of type *Resolver) as graphql.ExecutableSchema value in argument because the generated interface expects new methods. Run go generate or your generator command before building.

Another trap is circular references. If User has a friends field that returns [User], and User also has a bestFriend that returns User, the generator creates a struct with a slice of itself and a pointer to itself. Go handles this fine, but the JSON encoder will panic with json: encoding error if you accidentally create a reference cycle in your data. Always break cycles with a flag or a depth limit in your resolver.

Error handling follows Go standard pattern. Resolvers return (T, error). If you return an error, the server adds it to the errors array in the response and sets the field to null. The compiler complains with cannot use nil as string value in return argument if you try to return nil for a non-null scalar. Use the correct zero value or wrap the type in a pointer.

Convention aside: if err != nil { return nil, err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Do not swallow errors in resolvers. Let the GraphQL layer surface them.

The worst resolver bug is the one that silently returns the wrong shape. Run the generator after every schema change.

Picking the right toolchain

GraphQL in Go requires picking the right toolchain. The landscape splits into code-generation frameworks and runtime libraries.

Use gqlgen when you want compile-time safety and automatic type mapping. Use graphql-go/graphql when you prefer defining the schema entirely in Go code without a separate generator step. Use a raw JSON API with net/http when your clients do not need flexible field selection and you want to avoid schema management overhead. Use gRPC with a gateway when your internal services already speak protobuf and you only need a public-facing REST or GraphQL layer.

Schema generation removes boilerplate but adds a build step. Pick the trade-off that matches your team workflow.

Where to go next