Skip to content
gohacks.dev
APIs

GraphQL in Go With gqlgen From Schema to Running Server

Build a GraphQL server in Go with gqlgen: write the schema, generate the types, implement resolvers, handle errors and test the whole thing over HTTP.

13 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response, with the GraphQL logo.

GraphQL lets a client describe the shape of the data it wants and get exactly that back from one endpoint. The server publishes a typed schema, the client sends a query against it, and the server fills in only the fields that were asked for. That is attractive for front ends with many screens and for mobile clients where each round trip costs, and it is a poor fit where an API is a handful of simple resources. The comparison is made in GraphQL or REST; this article is the how, once you have decided.

Tested with Go 1.25.6, gqlgen v0.17.85, macOS on Apple Silicon (arm64)

In Go the most used library is gqlgen. It is schema first: you write the GraphQL schema, it generates the Go types and the plumbing, and you fill in the resolvers. This build takes a small library catalogue with books and authors from an empty directory to a tested server, and the next article, fixing N plus one queries with dataloaders, picks up the problem this one leaves open on purpose. It ran on Go 1.25.6.

Version note

gqlgen's latest release at the time of testing asked for Go 1.26, so this lab pins v0.17.85, which builds on Go 1.25. If you are on a newer toolchain, drop the pin. The workflow does not change between these versions.

The schema

The schema is the contract. Everything else is derived from it. Two object types that refer to each other, two queries and a mutation:

schema.graphqlsText
type Book {
  id: ID!
  title: String!
  author: Author!
}

type Author {
  id: ID!
  name: String!
  books: [Book!]!
}

type Query {
  books: [Book!]!
  book(id: ID!): Book
}

input NewBook {
  title: String!
  authorId: ID!
}

type Mutation {
  addBook(input: NewBook!): Book!
}

Things to notice. ID! means a non-null identifier, and the exclamation marks matter because they become pointer versus value in Go. book(id: ID!): Book returns a nullable Book: a missing id is null, not an error. NewBook is an input type, which is the only kind a mutation argument can use. Object types cannot be used as inputs, a rule that surprises people coming from JSON APIs.

Configuration

gqlgen reads gqlgen.yml. The minimal useful file says where the schema is and where to put the output:

gqlgen.ymlYAML
schema:
  - schema.graphqls
exec:
  filename: generated.go
  package: main
model:
  filename: models_gen.go
  package: main
models:
  Book:
    fields:
      author:
        resolver: true
  Author:
    fields:
      books:
        resolver: true

Two entries under models deserve a sentence. By default gqlgen binds a field to a struct field of the same name. Book.author and Author.books are relationships that your code will load on demand rather than store on the struct, so resolver: true tells gqlgen to generate a method for each instead of reading a field. Without it, author would be a plain *Author on Book and the query layer would expect you to have filled it in already.

gqlgen init normally writes this file and also a resolver: section that generates stub methods with panic("not implemented") bodies. I left the resolver section out and wrote the resolvers by hand because a stub file that gets rewritten is easy to damage by pasting code into the wrong receiver, and I found out the hard way. If you prefer the stubs, keep the section and edit the generated file.

To keep the generator as a pinned dependency rather than a global binary, add a tools.go file:

tools.goGo
//go:build tools

package main

import _ "github.com/99designs/gqlgen"

Then generate:

Shell
go run github.com/99designs/gqlgen generate
ls *.go
Text
generated.go
main.go
models_gen.go
resolvers.go
tools.go

That one command produced a 3,000 line generated.go, which holds the executable schema, argument parsing, field collection, and the interfaces your code implements, plus models_gen.go with Book, Author and NewBook. Do not edit either. Commit them, or generate in CI, but not a mix of both.

The resolvers

A resolver is a function that produces one field. gqlgen generated four interfaces from the schema, QueryResolver, MutationResolver, BookResolver and AuthorResolver, and the compiler will not let the program build until each method exists.

resolvers.goGo
package main

import (
	"context"
	"fmt"
	"sync"
)

type Resolver struct {
	mu      sync.Mutex
	authors map[string]*Author
	books   map[string]*Book
	owner   map[string]string // book id -> author id
	nextID  int
}

func NewResolver() *Resolver {
	r := &Resolver{authors: map[string]*Author{}, books: map[string]*Book{}, owner: map[string]string{}, nextID: 2}
	r.authors["a1"] = &Author{ID: "a1", Name: "Alan Donovan"}
	r.authors["a2"] = &Author{ID: "a2", Name: "Brian Kernighan"}
	r.books["b1"] = &Book{ID: "b1", Title: "The Go Programming Language"}
	r.owner["b1"] = "a1"
	r.books["b2"] = &Book{ID: "b2", Title: "The Unix Programming Environment"}
	r.owner["b2"] = "a2"
	return r
}

func (r *queryResolver) Books(ctx context.Context) ([]*Book, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	return []*Book{r.books["b1"], r.books["b2"]}, nil
}

func (r *queryResolver) Book(ctx context.Context, id string) (*Book, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	return r.books[id], nil // a nil book with no error becomes null, which the schema allows for book(id:)
}

func (r *mutationResolver) AddBook(ctx context.Context, input NewBook) (*Book, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	if _, ok := r.authors[input.AuthorID]; !ok {
		return nil, fmt.Errorf("author %q does not exist", input.AuthorID)
	}
	r.nextID++
	b := &Book{ID: fmt.Sprintf("b%d", r.nextID), Title: input.Title}
	r.books[b.ID] = b
	r.owner[b.ID] = input.AuthorID
	return b, nil
}

// The field resolvers below run only when a query selects the field.
func (r *bookResolver) Author(ctx context.Context, obj *Book) (*Author, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	return r.authors[r.owner[obj.ID]], nil
}

func (r *authorResolver) Books(ctx context.Context, obj *Author) ([]*Book, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	var out []*Book
	for _, id := range []string{"b1", "b2", "b3", "b4", "b5"} {
		if b, ok := r.books[id]; ok && r.owner[id] == obj.ID {
			out = append(out, b)
		}
	}
	return out, nil
}

func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
func (r *Resolver) Query() QueryResolver       { return &queryResolver{r} }
func (r *Resolver) Book() BookResolver         { return &bookResolver{r} }
func (r *Resolver) Author() AuthorResolver     { return &authorResolver{r} }

type (
	mutationResolver struct{ *Resolver }
	queryResolver    struct{ *Resolver }
	bookResolver     struct{ *Resolver }
	authorResolver   struct{ *Resolver }
)

Notice what the code does not contain: any JSON, any HTTP, any argument parsing. A resolver receives typed Go values (NewBook, a string id) and returns typed Go values. gqlgen takes care of everything between the wire and the function.

The Resolver struct is the dependency holder. In a real service it carries a database handle or repository interfaces, and each resolver method uses it. I used a map behind a mutex so the lab needs no database. The mutex is not decoration: gqlgen resolves sibling fields concurrently when the field resolvers are marked as such, and a map shared between requests must be protected anyway.

Two choices in the code are worth defending. Book returns the map lookup directly, so an unknown id yields a nil *Book and no error, which GraphQL serialises as null. Returning an error there would make "not found" look like a failure and, because book is nullable, would still produce null plus an error entry. Pick one convention for the schema and keep to it. And AddBook rejects an unknown author with an ordinary Go error, which gqlgen turns into an entry in the response's errors array.

Serving it and trying it

handler.New builds an http.Handler from the executable schema. You add the transports you want; this lab only needs POST. In a real server you would also add transport.Options and transport.GET as needed, and the websocket transport for subscriptions. The test program starts the server with httptest and sends six queries as a client would:

main.goGo
package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"

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

func main() {
	srv := handler.New(NewExecutableSchema(Config{Resolvers: NewResolver()}))
	srv.AddTransport(transport.POST{})

	ts := httptest.NewServer(srv)
	defer ts.Close()

	do := func(label, query string) {
		body := `{"query":` + fmt.Sprintf("%q", query) + `}`
		resp, err := http.Post(ts.URL, "application/json", strings.NewReader(body))
		if err != nil {
			panic(err)
		}
		b, _ := io.ReadAll(resp.Body)
		resp.Body.Close()
		fmt.Printf("%s\n  %d %s\n", label, resp.StatusCode, strings.TrimSpace(string(b)))
	}

	do("list with nested author", `{ books { title author { name } } }`)
	do("only titles (no author resolver runs)", `{ books { title } }`)
	do("missing book is null", `{ book(id: "zzz") { title } }`)
	do("mutation", `mutation { addBook(input: {title: "Notes on Go Modules", authorId: "a1"}) { id title author { name books { title } } } }`)
	do("resolver error", `mutation { addBook(input: {title: "Ghost", authorId: "nope"}) { id } }`)
	do("unknown field", `{ books { isbn } }`)
}
Text
list with nested author
  200 {"data":{"books":[{"title":"The Go Programming Language","author":{"name":"Alan Donovan"}},{"title":"The Unix Programming Environment","author":{"name":"Brian Kernighan"}}]}}
only titles (no author resolver runs)
  200 {"data":{"books":[{"title":"The Go Programming Language"},{"title":"The Unix Programming Environment"}]}}
missing book is null
  200 {"data":{"book":null}}
mutation
  200 {"data":{"addBook":{"id":"b3","title":"Notes on Go Modules","author":{"name":"Alan Donovan","books":[{"title":"The Go Programming Language"},{"title":"Notes on Go Modules"}]}}}}
resolver error
  200 {"errors":[{"message":"author \"nope\" does not exist","path":["addBook"]}],"data":null}
unknown field
  422 {"errors":[{"message":"Cannot query field \"isbn\" on type \"Book\".","locations":[{"line":1,"column":11}],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}],"data":null}

What the run shows

Nested selection works. The first query asks for each book's title and author name, and the response contains exactly those, nothing more. The Book.author resolver ran once per book to produce them.

Unselected fields cost nothing. The second query asks only for titles. The author resolver does not run. This is the benefit that justifies the extra machinery: expensive fields are paid for only when requested.

Missing data is null. book(id: "zzz") returned {"data":{"book":null}} with status 200. In GraphQL, a successful HTTP status does not mean a successful operation. The body carries the result.

The mutation returned a nested graph. addBook returned the new book, its author and the author's books in one round trip, which would have been three requests in REST. The new id is b3.

Errors have a place. The resolver error came back in errors with the path ["addBook"], and data is null because addBook is declared non-null (Book!) and the null propagates up to the nearest nullable parent, here the whole data object. That propagation rule is easy to forget: a failure inside a non-null field can blank a much larger part of the response than you expect. When a field can legitimately fail on its own, make it nullable.

Validation precedes execution. { books { isbn } } was rejected with status 422 and GRAPHQL_VALIDATION_FAILED before any resolver ran. Clients get precise messages with line and column. Servers get the guarantee that resolvers only ever see queries that match the schema.

Decisions you make after the first run

Status codes. Most GraphQL servers answer 200 for anything that executed, even with errors in the body, because a partial result is a valid result. Monitoring that counts only 5xx responses will miss most failures. Instrument the errors array instead. gqlgen offers an extension hook, AroundResponses, that sees every response.

Error formatting. Map domain errors to codes the client can switch on. gqlgen supports graphql.AddError with extensions, and a presenter function that rewrites errors. Do not send internal messages such as SQL text to a client; the same rule as in problem details responses.

Authentication. Do it in ordinary HTTP middleware that puts the user into the request context, then read it in resolvers. GraphQL has one endpoint, so path-based middleware cannot protect individual fields. Authorisation happens in the resolver or in a schema directive, and it must be checked per object, not once per request. That is the same broken access control problem described in BOLA and IDOR, and GraphQL makes it easier to commit because a single query reaches many objects.

Query cost. A client controls the shape of the query, and a nested query like author { books { author { books ... } } } can be made as deep as it likes. gqlgen has extension.FixedComplexityLimit, depth-limiting through complexity functions, and automatic persisted queries. Set a limit before you expose the endpoint to anyone you do not control. Also limit request body size and set timeouts on the server, as in the JSON size limits article.

Introspection and the playground. Introspection is useful in development and lets anyone enumerate your schema in production. Many teams disable it for public endpoints. The playground handler is a separate package, playground.Handler, and should not be mounted in production by default.

Scalars, enums and custom types

The schema above uses only built-in scalars. Real schemas soon need more. gqlgen ships ready-made marshallers for Time, Map, Upload and Any in github.com/99designs/gqlgen/graphql, and you opt in from the config:

YAML
models:
  Time:
    model: github.com/99designs/gqlgen/graphql.Time
  ID:
    model:
      - github.com/99designs/gqlgen/graphql.IntID

The second entry is the one most worth knowing. GraphQL IDs are strings on the wire, and by default gqlgen maps ID to Go string. If your database keys are integers you can bind ID to IntID and keep int in the resolvers, so the string conversion happens once at the edge and not in every resolver.

Enums become Go string types with generated constants, and the generated code validates incoming values for you. Add a status: Status! field with an enum and the compiler gives you StatusActive, StatusArchived and a check that nothing else gets through. For a field that is a closed set, prefer an enum over a string, and remember that adding a value later can break clients that switch exhaustively.

Problems the generator reports

unable to bind means a field has no matching struct field or method. Either add it to your model, mark the field resolver: true, or fix the name. The error text names both the schema type and the Go type, which is enough to find it.

Generated code does not compile after a schema change. Your resolver file no longer matches the interfaces. Run go build and let the compiler list the methods that changed. With the generated stubs this is a merge of old and new code, which is why the generator keeps the previous implementation in a comment block.

Nullability mismatches. A schema field String! bound to a Go *string or the reverse produces a compile error or a surprise nil dereference. Keep the schema's nullability and Go's pointer-ness in step, and treat a pointer in a model as "this may be absent".

Slow generation. gqlgen loads and type-checks your packages. Keep the generated package small, and keep large domain code out of it, so each run stays under a few seconds.

Project layout for a service

The lab keeps everything in package main for brevity. A service usually splits it:

Text
graph/
  schema.graphqls
  generated.go
  models_gen.go
  resolver.go
  schema.resolvers.go
gqlgen.yml
cmd/api/main.go
internal/library/    # domain code with no GraphQL imports

The rule worth keeping is that the domain package never imports the graph package. Resolvers translate between GraphQL types and your domain, and the domain stays testable without a GraphQL schema. If a resolver has more than a few lines of logic, that logic belongs in the domain package, where a plain unit test can reach it.

Testing the server

The program above is already a test in disguise: it exercises the generated handler over real HTTP. For a unit test suite, use client.New from gqlgen's client package, or send queries through httptest as the lab does, and assert on the decoded JSON. Test the cases that carry risk: unknown ids, authorisation failures, input validation and the null propagation described above. Keep the generated code out of coverage numbers.

For resolvers that call a database, test them against a real database, as described in integration tests for APIs, because the interesting bugs are in the queries, not in the resolver glue.

Where gqlgen fits and where it does not

gqlgen is a code generator, which suits Go well: the types are checked at compile time, the runtime does no reflection over your structs per request, and the generated code is plain Go you can read. The cost is the generation step, which has to be in your build and CI, and the bulk of generated code in your repository.

The library has real alternatives. graph-gophers/graphql-go is also schema first, and works with resolvers matched by reflection at start-up. Code-first libraries exist but are less common. If the main thing you want is a typed API for one front end, Connect RPC or plain REST with an OpenAPI contract will often be less to operate.

The GraphQL specification is at spec.graphql.org, and gqlgen's own documentation and configuration reference are at gqlgen.com. Run gqlgen generate whenever the schema changes, commit the diff, and let the compiler show you which resolvers are missing.

Taggedproject
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.