← Back to the section

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_case in json tags (order_id instead of orderId) — REST APIs settled on camelCase.
  • *string in response structs without omitempty — the client gets "note": null.
  • {"data": {...}} — an envelope around the object complicates client code.
  • "content": null on an empty collection — it must be [].
  • float64 for money — use int64 in minor units.
  • 200 OK on creation — it must be 201 Created with Location.

In short

  • JSON tags set the field names: json:"orderId" gives camelCase in the response.
  • omitempty removes empty fields from the JSON — no need for nulls in 2xx responses.
  • In response structs avoid *T — pointers produce null; use a value plus omitempty.
  • A collection with pagination: {"content": [...], "page": 1, "size": 20, "total": 150}.
  • An empty collection — "content": [], never null.
  • Creation — 201 Created + a Location header + the resource body.
  • Deletion — 204 No Content, empty body.
  • Money — int64 in kopecks/cents, not float64.
  • Enumerations — upper-case strings: "NEW", "CONFIRMED".
  • Query parameters — how to accept filters and page parameters.
  • RFC 9457 errors — how the error response is structured.
  • Headers — Location, Content-Type, Idempotency-Key.