Skip to content
gohacks.dev
APIs

Structure JSON Responses in Go With a Consistent Envelope

How a Go API should wrap its JSON: bare resources, a data envelope for lists, empty arrays instead of null, optional totals, and why to skip a success flag.

8 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response, with the JSON logo.

Every API faces the same small decision on its first endpoint: when the server returns a task, is the response the task, or an object that contains the task? The question looks cosmetic, and it is not, because the shape of a response is the contract that every client library, mobile screen and test will be built on. Changing it later breaks all of them at once. The decision deserves five minutes and a written answer.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article gives my answer, shows what each choice produces on the wire, and covers the Go details that make the difference between a clean convention and a pile of special cases: nil slices, optional counts and a generic list type. It supports the JSON API cornerstone, applies the rules from REST API design, and builds on marshaling JSON in Go. Everything ran on Go 1.25.6 with the standard library only.

The convention

  • A single resource is the object itself. GET /v1/tasks/7 returns {"id":7,"title":"write","done":false}. The status code already says whether it worked, and the body is the thing that was asked for.
  • A collection is an object with a data array. GET /v1/tasks returns {"data":[...],"next_cursor":"abc"}. The array is wrapped because a list response always grows metadata: a cursor, a count, links, a warning. An endpoint that returns a bare array has nowhere to put the cursor, and adding one later means changing the type of the top-level value from array to object, which breaks every client at once.
  • Errors are a separate document with the right status, such as a problem details body, not a variation of the success shape.
  • Optional things are absent, not null, unless null has a documented meaning.

Some teams wrap every response in {"data": ...} for uniformity, and that is a fair choice too. What matters is that the rule has no exceptions. A client written against an API where most endpoints return bare objects and a few return envelopes spends its life in special cases.

What each choice produces

The program below marshals the shapes discussed here and prints each with its size, then writes one response through a handler so the status and content type are visible. It uses a small generic List[T], so the convention is a type and not a habit.

main.goGo
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
)

type Task struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

// List wraps a collection with room for paging data. Items is never nil, so it marshals as [] and not null.
type List[T any] struct {
	Data       []T    `json:"data"`
	NextCursor string `json:"next_cursor,omitempty"`
	Total      *int   `json:"total,omitempty"` // a pointer so that zero can be told apart from "not computed"
}

func NewList[T any](items []T, next string) List[T] {
	if items == nil {
		items = []T{}
	}
	return List[T]{Data: items, NextCursor: next}
}

// Respond writes any value as JSON with the status first.
func Respond(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func main() {
	var none []Task
	three := 3
	cases := []struct {
		label string
		value any
	}{
		{"one resource, bare", Task{1, "write", false}},
		{"a list the careless way (nil slice)", none},
		{"a list through NewList(nil)", NewList(none, "")},
		{"a page with a cursor", NewList([]Task{{1, "a", false}, {2, "b", true}}, "abc")},
		{"a page with a total", List[Task]{Data: []Task{{1, "a", false}}, Total: &three}},
		{"the same page with Total zero", List[Task]{Data: []Task{}, Total: new(int)}},
		{"an old-style wrapper", map[string]any{"success": true, "data": Task{1, "write", false}, "error": nil}},
	}
	for _, c := range cases {
		b, _ := json.Marshal(c.value)
		fmt.Printf("%-38s %3d bytes  %s\n", c.label, len(b), b)
	}

	// The wire format over a real handler: status and content type are part of the contract too.
	rec := httptest.NewRecorder()
	Respond(rec, http.StatusCreated, Task{7, "ship", false})
	fmt.Printf("\nCreate: %d %s %s", rec.Code, rec.Header().Get("Content-Type"), rec.Body.String())
}
Text
one resource, bare                      37 bytes  {"id":1,"title":"write","done":false}
a list the careless way (nil slice)      4 bytes  null
a list through NewList(nil)             11 bytes  {"data":[]}
a page with a cursor                    97 bytes  {"data":[{"id":1,"title":"a","done":false},{"id":2,"title":"b","done":true}],"next_cursor":"abc"}
a page with a total                     54 bytes  {"data":[{"id":1,"title":"a","done":false}],"total":3}
the same page with Total zero           21 bytes  {"data":[],"total":0}
an old-style wrapper                    74 bytes  {"data":{"id":1,"title":"write","done":false},"error":null,"success":true}

Create: 201 application/json {"id":7,"title":"ship","done":false}

Read the lines in order.

One resource, bare is 37 bytes and is exactly what the client asked for.

A list the careless way is the first trap. A Go slice that is nil marshals to null, four bytes that look harmless and are not. A client written in JavaScript that does response.length or for (const t of response) throws on null. A nil slice appears whenever a query finds no rows and the code started from var tasks []Task, so the bug lives in the empty case that the demo data never exercises. The fix is to make the empty collection a non-nil slice, and the way to make that impossible to forget is a constructor: NewList replaces a nil slice with []T{}, and the output becomes {"data":[]}. The same applies to nested arrays inside objects, where omitempty would omit an empty list (hiding the field) and a nil slice would print null; decide which you mean and test it.

A page with a cursor shows the wrapper doing its job: the items and the cursor sit side by side, and next_cursor is omitted on the last page by omitempty, which is how a client learns that there is nothing more. The cursor itself should be opaque, as in pagination for APIs.

A page with a total and the same page with a zero total show why the count is a pointer. If Total were a plain int with omitempty, a real total of zero would be dropped, indistinguishable from "we did not compute it". With *int, nil is absent and a pointer to zero prints "total":0. Counting rows is expensive on large tables, so many APIs offer the total only on request (?include=total) or not at all, and the pointer expresses that honestly. This is the same distinction between missing, null and zero that the article on that topic covers for input.

An old-style wrapper is the shape to avoid. {"success":true,"data":{...},"error":null} repeats the HTTP status inside the body, carries a field that is always null on success, and doubled the size of this tiny response from 37 to 74 bytes. Worse, it invites inconsistency: the day someone returns "success": false with status 200, monitoring that counts non-2xx responses sees nothing wrong, retry logic does not retry, and clients must check two places. The status line exists so that the body does not have to say it again.

The last line shows the create response through a handler: 201, application/json, and the bare object. Writing the status before the body, as Respond does, is the order the standard library requires.

Tests keep the convention

A convention that lives in people's heads erodes. These three tests pin the Go-specific parts of it.

resp_test.goGo
package main

import (
	"encoding/json"
	"strings"
	"testing"
)

func TestEmptyCollectionsAreArraysNotNull(t *testing.T) {
	b, _ := json.Marshal(NewList[Task](nil, ""))
	if !strings.Contains(string(b), `"data":[]`) {
		t.Fatalf("got %s", b)
	}
}

func TestZeroTotalSurvivesButAbsentTotalIsOmitted(t *testing.T) {
	zero, absent := new(int), List[Task]{Data: []Task{}}
	a, _ := json.Marshal(List[Task]{Data: []Task{}, Total: zero})
	b, _ := json.Marshal(absent)
	if !strings.Contains(string(a), `"total":0`) || strings.Contains(string(b), "total") {
		t.Fatalf("with zero: %s, absent: %s", a, b)
	}
}

func TestListRoundTrips(t *testing.T) {
	in := NewList([]Task{{1, "a", false}}, "c1")
	b, _ := json.Marshal(in)
	var out List[Task]
	if err := json.Unmarshal(b, &out); err != nil || len(out.Data) != 1 || out.NextCursor != "c1" {
		t.Fatalf("%s -> %+v, %v", b, out, err)
	}
}
Text
=== RUN   TestEmptyCollectionsAreArraysNotNull
--- PASS: TestEmptyCollectionsAreArraysNotNull (0.00s)
=== RUN   TestZeroTotalSurvivesButAbsentTotalIsOmitted
--- PASS: TestZeroTotalSurvivesButAbsentTotalIsOmitted (0.00s)
=== RUN   TestListRoundTrips
--- PASS: TestListRoundTrips (0.00s)
PASS
ok  	example.test/lab	1.253s

The first fails if an empty collection ever marshals as anything but []. The second pins the pointer behavior for totals, in both directions. The third checks that the wrapper round-trips through Unmarshal into the same generic type, which is what a Go client built on the same package relies on. Add one assertion at the handler level too: every list endpoint's response for an empty table contains "data":[], which catches the endpoint that builds its own slice.

Questions that come up

Does the wrapper cost anything? For a list it adds about ten bytes: {"data": and a closing brace. That vanishes next to the items, and gzip removes most of what is left. For a single small object, wrapping it would add a few dozen bytes to a response of a few dozen, which is the argument for leaving single resources bare, but even there the cost is rarely a bottleneck compared with a database query.

What about metadata for single resources? If a resource needs side information (a permission set, a computed warning), put it inside the resource as fields, or return it in a header. Wrapping every single resource just to leave room for metadata that never arrives is the kind of speculative design that makes every client pay.

Should paging data go in headers instead? Some APIs use Link headers (as GitHub does) and X-Total-Count. It keeps the body a bare array. It also hides the paging information from clients that only read bodies, makes it awkward to cache or log, and cannot be seen in a browser's JSON viewer. Putting it in the body is easier to use and to document.

What about JSON:API and similar specifications? They define envelopes, relationships and sparse fieldsets in detail, and they come with libraries and tooling. If you adopt one, adopt all of it. Mixing a half-remembered subset into a homegrown API gives you the verbosity without the interoperability.

How do I change the shape of an existing API? Additively. Add new fields, keep the old ones until a deprecation date, and bump the version if a top-level type has to change, as in API versioning. The Go side of the encoding rules is documented in the encoding/json package, and the formal media type for errors is RFC 9457.

Taggedjson
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.