When you write a REST API, you need to agree on things: what format the data comes in, which HTTP statuses to return and how not to spawn nulls where nobody expects them. Let's go through it all in order.
How Go turns a struct into JSON
In Go any struct is turned into JSON via the json:"..." field. The field name in JSON is set by the tag, not by the field name in Go.
The problem without tags: Go exports fields with a capital letter (OrderID, CreatedAt), and by default they end up in the JSON exactly the same way — OrderID, CreatedAt. REST APIs are conventionally built with camelCase: orderId, createdAt.
The solution is json tags:
type OrderResponse struct {
OrderID string `json:"orderId"`
Status string `json:"status"`
Total int64 `json:"total"`
CreatedAt time.Time `json:"createdAt"`
}
Go serializes time.Time to ISO 8601 automatically: "2026-03-14T09:00:00Z". No extra configuration is needed.
omitempty — how to remove empty fields from the response
Sometimes a field is optional: an order may have no note. If in Go this is an empty string "", without extra configuration it still ends up in the JSON: "note": "". That's needless noise in the response.
Add omitempty — and the field simply disappears from the JSON when it's empty:
type OrderResponse struct {
OrderID string `json:"orderId"`
Note string `json:"note,omitempty"` // won't appear if ""
}
omitempty treats as "empty": the empty string "", zero 0, false and nil. For strings and numbers this is usually exactly what you want.
Why you shouldn't use pointers in responses
Sometimes people write *string (a pointer to a string) in response structs. The problem: when there's no value, Go serializes the pointer as null. And the client gets "note": null where it expected the field to simply be absent.
// common mistake — null ends up in the response
type Bad struct {
Note *string `json:"note"`
}
// correct — the field is simply absent
type Good struct {
Note string `json:"note,omitempty"`
}
Exception: in structs for PATCH requests a pointer is needed to distinguish "the field wasn't sent" from "null was sent deliberately, to remove the value". But that's only for incoming data, not for responses.
Helpers for writing the response
Instead of repeating the same lines in every handler, set up a couple of small functions:
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("writeJSON failed", "err", err)
}
}
func writeNoContent(w http.ResponseWriter) {
w.WriteHeader(http.StatusNoContent)
}
Now in the handlers it's one line instead of three.
A single resource — just a flat object
When the client requests one order, the response is just an object without extra wrappers:
func getOrder(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
order, err := svc.GetOrder(r.Context(), id)
if err != nil {
httperr.Write(w, r, err)
return
}
writeJSON(w, http.StatusOK, toOrderResponse(order))
}
The JSON response looks like this:
{
"orderId": "a3f2d1",
"customerId": "c-101",
"status": "CONFIRMED",
"total": 49900,
"currency": "RUB",
"createdAt": "2026-03-14T09:00:00Z",
"updatedAt": "2026-03-14T09:05:00Z",
"items": [...]
}
A common mistake is to wrap the object in an envelope: {"data": {...}}. That complicates client code with no benefit. Return the object directly.
A collection with pagination
When you need to return a list with page navigation, people use a single format: an array in the content field plus metadata about the page.
type PageResponse[T any] struct {
Content []T `json:"content"`
Page int `json:"page"`
Size int `json:"size"`
Total int `json:"total"`
}
func toPageResponse[T any](items []T, page, size, total int) PageResponse[T] {
if items == nil {
items = []T{} // empty slice, not nil
}
return PageResponse[T]{
Content: items,
Page: page,
Size: size,
Total: total,
}
}
The response looks like this:
{"content": [...], "page": 1, "size": 20, "total": 150}
An important point: if the list is empty, content must be [], not null:
{"content": [], "page": 1, "size": 20, "total": 0}
That's why toPageResponse has the if items == nil check — an uninitialized slice in Go serializes to null. It's a guard against an accidental mistake.
Creating a resource — 201 and the Location header
When the client creates a new resource, the correct response is 201 Created. Additionally you need to return a Location header with the address of the created resource — so the client immediately knows where to go for it.
func createOrder(w http.ResponseWriter, r *http.Request) {
var req CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperr.Write(w, r, apperr.NewValidation("invalid request body"))
return
}
order, err := svc.CreateOrder(r.Context(), toCreateOrderCommand(req))
if err != nil {
httperr.Write(w, r, err)
return
}
w.Header().Set("Location", "/api/v1/orders/"+order.ID)
writeJSON(w, http.StatusCreated, toOrderResponse(order))
}
I often see 200 OK on creation — that's wrong. 200 means "the request completed, the resource already existed". 201 says "a new resource was created".
Update and delete
On an update via PUT or PATCH, return 200 OK and the updated resource:
func patchOrder(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req PatchOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperr.Write(w, r, apperr.NewValidation("invalid request body"))
return
}
order, err := svc.PatchOrder(r.Context(), id, req.Note)
if err != nil {
httperr.Write(w, r, err)
return
}
writeJSON(w, http.StatusOK, toOrderResponse(order))
}
On delete — 204 No Content with an empty body:
func deleteOrder(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := svc.DeleteOrder(r.Context(), id); err != nil {
httperr.Write(w, r, err)
return
}
writeNoContent(w)
}
Money and enumerations
Two spots where people often make mistakes:
Money. Never use float64 for monetary values — floating point accumulates error. Use int64 in minor units: kopecks, cents. The value 49900 in the response means 499.00 ₽. The client divides by 100 for display.
Enumerations. Return statuses and categories as upper-case strings: "NEW", "CONFIRMED", "SHIPPED". It's readable and unambiguous.
type OrderResponse struct {
Status string `json:"status"` // "NEW", "CONFIRMED", "SHIPPED"
Total int64 `json:"total"` // 49900 = 499.00 RUB
Currency string `json:"currency"` // "RUB"
}
Common mistakes
snake_casein json tags (order_idinstead oforderId) — REST APIs settled oncamelCase.*stringin response structs withoutomitempty— the client gets"note": null.{"data": {...}}— an envelope around the object complicates client code."content": nullon an empty collection — it must be[].float64for money — useint64in minor units.200 OKon creation — it must be201 CreatedwithLocation.
In short
- JSON tags set the field names:
json:"orderId"givescamelCasein the response. omitemptyremoves empty fields from the JSON — no need fornulls in 2xx responses.- In response structs avoid
*T— pointers producenull; use a value plusomitempty. - A collection with pagination:
{"content": [...], "page": 1, "size": 20, "total": 150}. - An empty collection —
"content": [], nevernull. - Creation —
201 Created+ aLocationheader + the resource body. - Deletion —
204 No Content, empty body. - Money —
int64in kopecks/cents, notfloat64. - Enumerations — upper-case strings:
"NEW","CONFIRMED".
What to read next
- Query parameters — how to accept filters and page parameters.
- RFC 9457 errors — how the error response is structured.
- Headers —
Location,Content-Type,Idempotency-Key.