GraphQL or REST for Your Next Go Backend Service Design
Compare GraphQL and REST for a Go backend with measured request counts and payload sizes, then use the trade-offs to choose for your own clients.

The question comes up on every new service with a front end: should this be GraphQL or REST? The loudest answers come from people who have already picked a side. The useful answer is that the two solve overlapping problems with different costs, and the right choice depends on who calls the API, how connected the data is and who runs the thing at three in the morning.
Tested with Go 1.25.6, graphql-go v0.8.1, macOS on Apple Silicon (arm64)
This article compares them for a Go backend, with a small measurement to anchor the discussion. Later sections cover the costs that rarely make it into the comparison charts: caching, rate limiting, errors and the operational load. If you already know you want REST, designing a REST API in Go is the next step, and if you chose GraphQL, the gqlgen walkthrough builds the server. Everything here ran on Go 1.25.6.
The measurement
The task is a screen that lists books with the author's name next to each. The data has ten books by three authors, and each book and author record carries extra fields that this screen does not need: a description, an ISBN, a biography and an email address. The lab serves the same data three ways and counts what the client receives.
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"github.com/graphql-go/graphql"
)
type Author struct {
ID string `json:"id"`
Name string `json:"name"`
Bio string `json:"bio"`
Email string `json:"email"`
}
type Book struct {
ID string `json:"id"`
Title string `json:"title"`
AuthorID string `json:"author_id"`
Description string `json:"description"`
ISBN string `json:"isbn"`
Pages int `json:"pages"`
}
var authors = map[string]Author{}
var books []Book
func init() {
for i := 0; i < 10; i++ {
id := fmt.Sprintf("a%d", i%3)
authors[id] = Author{ID: id, Name: "Author " + id, Bio: strings.Repeat("Writes about Go and systems. ", 6), Email: id + "@example.test"}
books = append(books, Book{ID: fmt.Sprintf("b%d", i), Title: fmt.Sprintf("Book %d", i), AuthorID: id,
Description: strings.Repeat("A long description of the book. ", 8), ISBN: fmt.Sprintf("978-0-00-%06d-0", i), Pages: 200 + i})
}
}
func main() {
var restCalls atomic.Int64
mux := http.NewServeMux()
mux.HandleFunc("GET /books", func(w http.ResponseWriter, r *http.Request) {
restCalls.Add(1)
json.NewEncoder(w).Encode(books)
})
mux.HandleFunc("GET /authors/{id}", func(w http.ResponseWriter, r *http.Request) {
restCalls.Add(1)
json.NewEncoder(w).Encode(authors[r.PathValue("id")])
})
// A purpose-built endpoint for this one screen: the honest REST competitor to the GraphQL query.
mux.HandleFunc("GET /views/book-list", func(w http.ResponseWriter, r *http.Request) {
restCalls.Add(1)
type row struct {
Title string `json:"title"`
Author string `json:"author"`
}
rows := make([]row, 0, len(books))
for _, b := range books {
rows = append(rows, row{b.Title, authors[b.AuthorID].Name})
}
json.NewEncoder(w).Encode(rows)
})
authorType := graphql.NewObject(graphql.ObjectConfig{Name: "Author", Fields: graphql.Fields{
"id": &graphql.Field{Type: graphql.String}, "name": &graphql.Field{Type: graphql.String},
"bio": &graphql.Field{Type: graphql.String}, "email": &graphql.Field{Type: graphql.String},
}})
bookType := graphql.NewObject(graphql.ObjectConfig{Name: "Book", Fields: graphql.Fields{
"id": &graphql.Field{Type: graphql.String}, "title": &graphql.Field{Type: graphql.String},
"pages": &graphql.Field{Type: graphql.Int}, "isbn": &graphql.Field{Type: graphql.String},
"author": &graphql.Field{Type: authorType, Resolve: func(p graphql.ResolveParams) (any, error) {
return authors[p.Source.(Book).AuthorID], nil
}},
}})
schema, err := graphql.NewSchema(graphql.SchemaConfig(graphql.SchemaConfig{
Query: graphql.NewObject(graphql.ObjectConfig{Name: "Query", Fields: graphql.Fields{
"books": &graphql.Field{Type: graphql.NewList(bookType), Resolve: func(graphql.ResolveParams) (any, error) { return books, nil }},
}}),
}))
if err != nil {
panic(err)
}
var gqlCalls atomic.Int64
mux.HandleFunc("POST /graphql", func(w http.ResponseWriter, r *http.Request) {
gqlCalls.Add(1)
var in struct{ Query string }
json.NewDecoder(r.Body).Decode(&in)
json.NewEncoder(w).Encode(graphql.Do(graphql.Params{Schema: schema, RequestString: in.Query}))
})
srv := httptest.NewServer(mux)
defer srv.Close()
// The screen needs: each book's title and its author's name.
total := 0
get := func(path string) []byte {
resp, _ := http.Get(srv.URL + path)
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
total += len(b)
return b
}
var list []Book
json.Unmarshal(get("/books"), &list)
seen := map[string]bool{}
for _, b := range list {
if !seen[b.AuthorID] { // a careful client caches authors it has already fetched
seen[b.AuthorID] = true
get("/authors/" + b.AuthorID)
}
}
fmt.Printf("REST: %d requests, %5d bytes received\n", restCalls.Load(), total)
restCalls.Store(0)
total = 0
get("/views/book-list")
fmt.Printf("REST view endpoint: %d request, %5d bytes received\n", restCalls.Load(), total)
q := `{ books { title author { name } } }`
body, _ := json.Marshal(map[string]string{"query": q})
resp, _ := http.Post(srv.URL+"/graphql", "application/json", strings.NewReader(string(body)))
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
fmt.Printf("GraphQL: %d request, %5d bytes received\n", gqlCalls.Load(), len(b))
fmt.Println(string(b)[:96] + "...")
// Asking for a field that exists in the data but not in the schema is an error, not extra data.
body, _ = json.Marshal(map[string]string{"query": `{ books { title description } }`})
resp, _ = http.Post(srv.URL+"/graphql", "application/json", strings.NewReader(string(body)))
b, _ = io.ReadAll(resp.Body)
resp.Body.Close()
fmt.Println(strings.TrimSpace(string(b)))
}REST: 4 requests, 4302 bytes received
REST view endpoint: 1 request, 402 bytes received
GraphQL: 1 request, 511 bytes received
{"data":{"books":[{"author":{"name":"Author a0"},"title":"Book 0"},{"author":{"name":"Author a1"...
{"data":null,"errors":[{"message":"Cannot query field \"description\" on type \"Book\".","locations":[{"line":1,"column":17}]}]}The three results:
| Approach | Requests | Bytes received |
|---|---|---|
| Generic REST: list books, then fetch each distinct author | 4 | 4302 |
| GraphQL: one query for titles and author names | 1 | 511 |
| REST endpoint built for this screen | 1 | 402 |
The first row is the classic over-fetching and under-fetching problem. The list endpoint returns every field of every book whether the screen wants them or not, and the author data needs separate calls. Even with a client smart enough to fetch each author only once (three authors, so three extra calls), it takes four requests and more than eight times the bytes.
The second row is what GraphQL buys you. One request, and the payload carries only what the query named.
The third row is the part people leave out. A single endpoint that returns title and author name for this screen is smaller than the GraphQL response, because it has no field-name nesting, and it is also one request. The GraphQL advantage over a well-designed REST API is not payload size. It is that the client can ask for a different shape tomorrow without anyone changing the server.
That is the real trade, and it deserves to be said plainly: GraphQL moves the work of designing screen-specific responses from the server team to the client team.
When GraphQL earns its place
Many clients with different needs. A web app, two mobile apps and a partner integration all want different fields from the same data. Maintaining a view endpoint for each means a backlog of requests to the backend team. A schema lets each client select its own.
Deeply connected data. Social graphs, catalogues with categories, products, variants and reviews. When a screen walks four relationships, a single query is much clearer than four chained REST calls.
Fast-moving front ends. If the product changes weekly and the API team is the bottleneck, GraphQL lets the front end iterate without waiting. The schema must still evolve carefully, but adding a field is a non-breaking change by construction.
A typed contract and tooling. Introspection, generated clients and a strict validator are built in. The same can be had with OpenAPI, but there it is an add-on you must keep honest, as discussed in shared contracts.
When REST is the better choice
Few resources, few clients. A service with a dozen endpoints, called by one front end that you also control, does not need a query language. A well-shaped REST API is easier to read, test and debug with curl.
Caching matters. HTTP caching works on URLs. GET /books/42 can be cached by a CDN, a reverse proxy and the browser, with ETag and Cache-Control doing their jobs, as in the ETag article. GraphQL usually sends a POST to one URL, so none of that applies without extra machinery: persisted queries over GET, or a separate cache keyed by query and variables.
Public APIs with limits. Rate limiting REST is easy because requests are roughly comparable: count them. In GraphQL a single request can be tiny or can ask for a million rows through nested lists. You need query cost analysis, depth limits and timeouts, or a client can take your database down with one valid request. See API rate limiting for the plain version of the problem.
Simple operations. Uploading files, streaming, webhooks and long operations are awkward in GraphQL and natural in HTTP. Most GraphQL deployments keep some REST endpoints for exactly these.
Operational familiarity. Every load balancer, proxy, log pipeline and monitoring tool understands HTTP status codes. Which brings us to errors.
Errors and visibility
In REST, a failing request has a 4xx or 5xx status. Dashboards count them and alerts fire on them. In GraphQL the usual convention is status 200 with an errors array in the body, because a response can be partly successful. The lab's last request shows the validation case: asking for description, a field that exists in the data but not in the schema, comes back as Cannot query field "description" on type "Book" with a location. That is a good error. It is also invisible to a dashboard that watches status codes unless you teach it to read the body.
Plan for this from the start. Log the operation name and error codes of every request, put them in metrics, and give clients a stable way to tell a failed field from a failed request.
The N plus one cost
REST makes you pay for joins in round trips; GraphQL makes you pay in resolver calls. A list of twenty books asks the author resolver twenty times unless you batch. The lab above hides this because the data is in memory. In a real service you need a dataloader for most relationships, which is more code to write and test than a join in a handler.
Working in Go
Go suits both. The standard library with a router gives you a clear REST API with almost no dependencies, and Go 1.22's mux patterns removed the main reason to reach for a router library. GraphQL needs a library and, with gqlgen, a code generation step in your build. The generated code is typed and fast, but it is a lot of code in the repository and a tool to keep pinned.
Concurrency is the one place Go shines for GraphQL: resolvers for independent fields can run in separate goroutines, and loaders that batch across them depend on that.
A decision procedure
Answer these in order and stop at the first that settles it.
- Is the API public, rate limited or CDN cached? Use REST.
- Does one team own the server and the only client? Use REST, and add screen-specific endpoints where a screen needs them.
- Do three or more clients need different shapes of connected data, and does the backend team keep being asked for new endpoints? Use GraphQL.
- Do you want a typed RPC between your own services? Neither. Use gRPC or Connect RPC.
You can also do both. A common arrangement is a GraphQL layer for the web and mobile front ends, backed by REST or gRPC services, so the GraphQL service is a thin aggregator and the services behind it stay simple and cacheable.
A mistake to avoid
Do not choose GraphQL to avoid designing the API. The schema still needs careful naming, nullability decisions, pagination and a deprecation policy. The work is the same, and it becomes visible later if skipped now. Equally, do not choose REST and let a hundred ad hoc endpoints accumulate, one per screen, with inconsistent names. Either decision needs an owner who cares about the shape.
The specification for GraphQL is published at graphql.org, the HTTP semantics REST relies on are in RFC 9110, and the Go standard library routing documentation covers the patterns used by the REST side here. Measure your own screens as the lab does before you let anyone sell you a number.


