REST vs GraphQL in Go

When to Use Which

Web
Use REST for simple, cacheable resources and GraphQL for flexible, complex data fetching where clients control the response shape.

The API choice that actually matters

Your frontend team needs a user profile page. They ask for an endpoint that returns the user, their recent orders, and the shipping status for each order. You build three REST endpoints. Two weeks later, they launch a dashboard that needs the same data plus payment history and support tickets. You build a fourth endpoint that joins everything. The frontend complains that the profile page is downloading shipping data it does not use. The dashboard complains that the profile endpoint does not include payment history. You are stuck in the classic API scaling trap.

Go handles both sides of this problem with the same standard library primitives. The difference is not in the language. The difference is in how you route requests, shape responses, and manage data fetching. REST maps directly to HTTP methods and URL paths. GraphQL maps to a single endpoint that parses a query language and executes field resolvers. Both run on net/http. Both use encoding/json. Both respect Go conventions. The architecture you pick determines how your codebase grows.

What REST and GraphQL are doing under the hood

REST treats your API like a file system. Each resource gets a path. GET /users/123 returns a user. GET /users/123/orders returns orders. The server decides the shape of the response. The client accepts it or ignores extra fields. Caching works naturally because URLs are stable. HTTP status codes carry meaning. 200 means success. 404 means missing. 422 means validation failed.

GraphQL treats your API like a query engine. The client sends a single POST request to /graphql. The body contains a query string that declares exactly which fields it needs. The server parses the query, matches it against a schema, and runs resolver functions for each field. The response shape matches the request exactly. No extra data. No missing fields. Caching requires custom logic because the URL never changes. HTTP status codes mostly stay 200. Errors live inside the JSON response.

Think of REST as ordering from a fixed menu. You pick item number three, and the kitchen serves it exactly as designed. Think of GraphQL as a custom meal request. You describe the ingredients, and the kitchen assembles them on the spot. One approach scales by adding endpoints. The other scales by adding resolvers.

REST endpoints are cheap to cache. GraphQL queries are cheap to compose.

A minimal REST handler in Go

Here is the simplest REST handler that follows Go conventions. It reads a path parameter, queries a database, and returns JSON.

// GetUser returns a single user by ID from the database.
func GetUser(ctx context.Context, w http.ResponseWriter, r *http.Request) {
    // Extract ID from the URL path. net/http does not parse path params automatically.
    id := chi.URLParam(r, "id")
    if id == "" {
        http.Error(w, "missing user id", http.StatusBadRequest)
        return
    }

    // Query the database. Always pass context to respect client timeouts.
    user, err := db.FindUser(ctx, id)
    if err != nil {
        // Return 500 for internal errors. The client cannot fix a database failure.
        http.Error(w, "internal server error", http.StatusInternalServerError)
        return
    }

    // Marshal to JSON. Set content type before writing the body.
    w.Header().Set("Content-Type", "application/json")
    if err := json.NewEncoder(w).Encode(user); err != nil {
        // Log the error. The response is already partially sent.
        log.Printf("failed to encode user: %v", err)
    }
}

The handler follows the standard http.HandlerFunc signature. context.Context is the first parameter. The community expects it there so cancellation and deadlines flow through every layer. if err != nil checks are verbose by design. They make the unhappy path visible. gofmt aligns the braces automatically. Do not fight the formatter. Trust it.

How the request flows through net/http

When a client hits /users/123, the Go HTTP router matches the path and calls GetUser. The function extracts the ID, passes the context to the database driver, and waits for the result. If the query succeeds, json.NewEncoder streams the struct directly to the http.ResponseWriter. No intermediate byte slice. No extra allocation. The connection closes when the response finishes.

Go runs each incoming HTTP request in its own goroutine. The standard library handles the concurrency. You do not need to spawn workers manually. The runtime schedules the goroutine, blocks on the database call, and resumes when the result arrives. If the client cancels the request, the context triggers a cancellation signal. The database driver sees it and aborts the query. The handler returns early. The connection pool stays healthy.

This is why context.Context always travels first. It is plumbing, not decoration. Run it through every long-lived call site.

A minimal GraphQL setup

GraphQL in Go usually relies on a code generator like gqlgen or a runtime like graphql-go. The pattern is the same: define a schema, write resolvers, wire them to a single HTTP handler. Here is a resolver that fetches a user and their orders.

// UserResolver fetches user data and nested orders from the database.
func (r *UserResolver) Orders(ctx context.Context, obj *model.User) ([]*model.Order, error) {
    // Resolve orders for the specific user. The parent object provides the ID.
    orders, err := db.FindOrdersByUser(ctx, obj.ID)
    if err != nil {
        // GraphQL collects resolver errors and returns them in the response body.
        return nil, fmt.Errorf("fetching orders: %w", err)
    }

    // Return the slice. The GraphQL engine will iterate and resolve nested fields.
    return orders, nil
}

The resolver receives the parent object and the context. It returns a value and an error. The GraphQL engine calls it only if the client requested the orders field. If the client only asked for name and email, this function never runs. That is the core optimization. Field-level execution replaces endpoint-level execution.

The receiver name is one letter matching the type. (r *UserResolver) follows Go convention. Not (this *UserResolver). Not (self *UserResolver). Keep it short. The compiler does not care about the name, but the community expects consistency.

Real-world wiring: HTTP vs GraphQL in production

In production, REST and GraphQL share the same transport layer. Both run behind net/http. Both use middleware for logging, authentication, and rate limiting. The difference is how you attach the middleware.

REST handlers chain naturally. You wrap GetUser with an auth middleware, then a logging middleware, then a CORS middleware. Each wrapper calls next.ServeHTTP. The stack is explicit. You can inspect the URL path at every layer.

GraphQL uses a single handler. The middleware wraps the entire /graphql endpoint. Authentication happens once per request. The GraphQL engine then dispatches to resolvers. If a resolver needs the authenticated user, it reads it from the context. The context becomes the shared state bag.

// GraphQLHandler serves the single GraphQL endpoint with auth and logging.
func GraphQLHandler(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Attach request ID and auth user to context before execution.
        ctx := context.WithValue(r.Context(), "requestID", uuid.New().String())
        ctx = context.WithValue(ctx, "authUser", extractToken(r))

        // Log the request duration. GraphQL queries can be expensive.
        start := time.Now()
        next.ServeHTTP(w, r.WithContext(ctx))
        log.Printf("graphql query took %v", time.Since(start))
    })
}

The middleware pattern is identical. The context carries the payload. The GraphQL engine reads it during resolver execution. This keeps the transport layer clean and the business logic isolated.

Where things break

REST breaks when clients need data from multiple resources. You build aggregation endpoints. The endpoints duplicate logic. The frontend over-fetches. The backend under-fetches. You add version headers. You maintain /v1, /v2, and /v3. The codebase fragments.

GraphQL breaks when resolvers run independent database queries. The N+1 problem appears instantly. A query for ten users triggers ten separate order queries. The database chokes. You need data loaders or batch resolvers. You also lose HTTP caching. Every request hits the server. You must implement query complexity limits to prevent deep nesting attacks.

The compiler catches some mistakes early. If you forget to return an error from a resolver, you get not enough return values during compilation. If you pass a channel to json.Marshal, the runtime panics with json: unsupported type: chan bool. If you write headers after the body starts streaming, the server logs http: superfluous response.WriteHeader call. These errors are plain text. Read them. They tell you exactly where the contract broke.

The worst API bug is the one that silently returns empty data. Validate inputs. Check errors. Log failures.

Picking the right tool

Use REST when your data model is flat and stable. Use REST when HTTP caching matters more than query flexibility. Use REST when you want standard status codes and simple routing. Use REST when your team is small and you want to ship fast with zero schema overhead.

Use GraphQL when clients need nested data from multiple sources. Use GraphQL when mobile apps need to control bandwidth by requesting only specific fields. Use GraphQL when you want a single endpoint that evolves without versioning. Use GraphQL when your frontend team changes frequently and needs to iterate without waiting for backend releases.

Use plain sequential code when you do not need a public API. Use an internal RPC pattern when services talk to each other over a trusted network. Use REST for public APIs that third parties will cache. Use GraphQL for internal dashboards and mobile clients that shape their own data.

Pick the architecture that matches your data access pattern. Do not pick it based on trends.

Where to go next