Tips on writing JSON REST APIs in Go

Vladimir Mihailenco
February 07, 2026
4 min read

Go's standard library is powerful enough to build production-ready JSON APIs without external frameworks. This article covers practical patterns and tips for writing JSON REST APIs using plain Go HTTP handlers.

Use anonymous types to parse JSON

Instead of declaring global types:

go
type ArticleRequest struct {
    Name string
}

func handler(w http.ResponseWriter, req *http.Request) {
    article := new(ArticleRequest)
    if err := json.NewDecoder(req.Body).Decode(article); err != nil {
        panic(err)
    }
}

You can declare an anonymous inline type instead:

go
func handler(w http.ResponseWriter, req *http.Request) {
    var in struct {
        Name string
    }
    if err := json.NewDecoder(req.Body).Decode(&in); err != nil {
        panic(err)
    }
}

Pros:

  • No dangling types.
  • Handlers are decoupled from each other.
  • Types are declared where they are used.

Use http.MaxBytesReader to limit requests length

By default, Go does not impose any limits on the length of incoming requests. You should take care of that yourself using MaxBytesReader.

go
func handler(w http.ResponseWriter, req *http.Request) {
    req.Body = http.MaxBytesReader(w, req.Body, 1<<20) // 1MB
}

To quickly calculate number of bytes, use this trick:

  • 3 << 10 - 3 kilobytes.
  • 3 << 20 - 3 megabytes.
  • 3 << 30 - 3 gigabytes.

Use mapstringany to generate JSON

Usually it is not worth it to declare a struct to generate a JSON response. It is easier to use a map and just a tiny bit slower. Some frameworks even provide a short type alias for map[string]any, for example, gin.H or treemux.H.

go
type H map[string]any

func handler(w http.ResponseWriter, req *http.Request) {
    if err := json.NewEncoder(w).Encode(H{
        "articles": articles,
        "count": count,
    }); err != nil {
        panic(err)
    }
}

Use MarshalJSON to customize JSON output

You could write the following code to customize JSON output, but it fails with fatal error: stack overflow error.

go
type User struct{
    Name string
}

func (u *User) MarshalJSON() ([]byte, error) {
    if u.Name == "" {
        u.Name = "anonymous"
    }
    // This call causes infinite recursion.
    return json.Marshal(u)
}

You can fix it by declaring a new type using the original type as a base:

go
type jsonUser User

func (u *User) MarshalJSON() ([]byte, error) {
    if u.Name == "" {
        u.Name = "anonymous"
    }
    return json.Marshal((*jsonUser)(u))
}

Use middlewares to handle errors

Instead of writing code like this:

go
func handler(w http.ResponseWriter, req *http.Request) {
    if err := processRequest(req); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    if err := json.NewEncoder(w).Encode(H{}); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
}

You could create a middleware that handles errors for you:

go
func handler(w http.ResponseWriter, req *http.Request) error {
    if err := processRequest(req); err != nil {
        return err
    }
    if err := json.NewEncoder(w).Encode(H{}); err != nil {
        return err
    }
    return nil
}

func errorHandler(next func(w http.ResponseWriter, req *http.Request) error) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
        if err := next(w, req); err != nil {
            // You should change status code depending on the error.
            http.Error(w, err.Error(), http.StatusBadRequest)
        }
    })
}

Or you could use Echo or treemux which provide such functionality out-of-the-box.

Use structs to group handlers

Instead of using plain functions:

go
const rowLimit = 100
const rateLimit = 10

func showUser(w http.ResponseWriter, req *http.Request) {}
func listUsers(w http.ResponseWriter, req *http.Request) {}
func delUser(w http.ResponseWriter, req *http.Request) {}

It is better to define a struct and store all related state there:

go
type UserHandler struct{
    rowLimit  int
    rateLimit int
}

func (h *UserHandler) Show(w http.ResponseWriter, req *http.Request) {}
func (h *UserHandler) List(w http.ResponseWriter, req *http.Request) {}
func (h *UserHandler) Del(w http.ResponseWriter, req *http.Request) {}

Set Content-Type header

json.NewEncoder does not set the Content-Type header for you. Always set it explicitly before writing the response body:

go
func handler(w http.ResponseWriter, req *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(H{"status": "ok"})
}

Without this header, clients may not parse the response as JSON, leading to subtle bugs.

Use Go 1.22+ enhanced routing

Since Go 1.22, http.ServeMux supports method-based routing and path parameters, reducing the need for third-party routers:

go
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", showUser)
mux.HandleFunc("GET /users", listUsers)
mux.HandleFunc("DELETE /users/{id}", deleteUser)

You can extract path parameters using req.PathValue:

go
func showUser(w http.ResponseWriter, req *http.Request) {
    id := req.PathValue("id")
    // ...
}

This covers most routing needs without adding a dependency.

Use a faster JSON library

If encoding/json is a bottleneck, consider go-json or sonic as faster drop-in replacements:

diff
-import "encoding/json"
+import "github.com/goccy/go-json"

These libraries provide 2-3x faster encoding and decoding while maintaining API compatibility with the standard library. They also work well with []byte for zero-allocation encoding:

go
func Marshal(v any) ([]byte, error)
func Unmarshal(data []byte, v any) error

You may also be interested in: