Tips on writing JSON REST APIs in Go
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:
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:
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.
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.
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.
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:
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:
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:
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:
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:
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:
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:
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:
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:
-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:
func Marshal(v any) ([]byte, error)
func Unmarshal(data []byte, v any) error
You may also be interested in: