Reading and Writing JSON in Go with encoding/json
1. Overview
JSON is the most common data exchange format between services. Go has built-in JSON support in the standard library through the encoding/json package—you don’t need to install anything extra.
This guide helps you convert structs to JSON and back again, control field names using struct tags, handle dynamic JSON, and stream large data. You’ll also learn how to write custom types and test them. At the end, you’ll see common errors to avoid when shipping code to production.
The examples use Go 1.24 or later.
2. Writing JSON with json.Marshal
Let’s start with an order that contains customer information:
package main
import (
"encoding/json"
"fmt"
"log"
)
type Customer struct {
Name string `json:"name"`
Email string `json:"email,omitempty"`
}
type Order struct {
ID int `json:"id"`
Customer Customer `json:"customer"`
Items []string `json:"items"`
Total float64 `json:"total"`
Note string `json:"-"`
createdBy string
}
func main() {
order := Order{
ID: 1001,
Customer: Customer{Name: "Lan"},
Items: []string{"Coffee", "Bread"},
Total: 45000,
Note: "deliver before 9am",
createdBy: "admin",
}
data, err := json.Marshal(order)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(data))
}
Output:
{"id":1001,"customer":{"name":"Lan"},"items":["Coffee","Bread"],"total":45000}
A few things to note in the code above:
json.Marshalreturns[]byteanderror. You always need to checkerror.- The tag
json:"id"changes the field name toidin JSON. Without a tag, Go uses the original field nameID. Emailis empty, so it’s omitted thanks toomitempty.Notehas tagjson:"-"so it’s always skipped.createdBystarts with a lowercase letter, meaning it’s unexported.encoding/jsoncan’t see this field.- UTF-8 characters are preserved as-is and not escaped.
If you need pretty-printed JSON, for example when logging or writing config files, use json.MarshalIndent:
data, _ := json.MarshalIndent(order.Customer, "", " ")
fmt.Println(string(data))
{
"name": "Lan"
}
3. Struct tags: controlling output in detail
The json tag has the form json:"name,option,...". Common options:
| Tag | Meaning |
|---|---|
json:"name" |
Change the field name |
json:",omitempty" |
Omit the field if it’s a zero value: "", 0, false, nil, empty slice or map |
json:",omitzero" |
Omit the field if it’s a zero value, including structs like time.Time (Go 1.24+) |
json:",string" |
Write numbers or bools as strings |
json:"-" |
Always omit |
omitempty has no effect on structs. The example below shows the difference:
type Shipment struct {
OrderID int64 `json:"order_id,string"`
ShippedAt time.Time `json:"shipped_at,omitempty"`
PaidAt time.Time `json:"paid_at,omitzero"`
}
data, _ := json.Marshal(Shipment{OrderID: 1001})
fmt.Println(string(data))
{"order_id":"1001","shipped_at":"0001-01-01T00:00:00Z"}
ShippedAt still appears because omitempty doesn’t treat an empty time.Time struct as “empty”. PaidAt uses omitzero so it’s correctly omitted. OrderID is written as the string "1001" thanks to the string option. This is useful when the client is JavaScript and can’t read 64-bit integers accurately.
4. Reading JSON with json.Unmarshal
For the reverse direction, pass a pointer to the destination variable:
input := []byte(`{
"id": 1002,
"Customer": {"name": "Minh", "email": "minh@example.com"},
"items": ["Peach Tea"],
"total": 20000,
"coupon": "SALE10"
}`)
var order Order
if err := json.Unmarshal(input, &order); err != nil {
log.Fatal(err)
}
fmt.Printf("%+v\n", order)
{ID:1002 Customer:{Name:Minh Email:minh@example.com} Items:[Peach Tea] Total:20000 Note: createdBy:}
Notice two default behaviors:
- Keys are matched case-insensitively.
"Customer"still matches the tagcustomer. - Unknown keys like
"coupon"are silently ignored. Fields not in the JSON keep their current value.
Note: If you pass a value instead of a pointer, like
json.Unmarshal(input, order), you’ll get an errorjson: Unmarshal(non-pointer main.Order). The data isn’t written anywhere.
4.1. Rejecting unknown fields
With APIs that accept requests from clients, ignoring unknown keys often hides typos. You can enable strict mode using json.Decoder:
dec := json.NewDecoder(bytes.NewReader(input))
dec.DisallowUnknownFields()
var order Order
err := dec.Decode(&order)
fmt.Println(err)
json: unknown field "coupon"
DisallowUnknownFields only exists on Decoder, not as an option for json.Unmarshal.
5. Dynamic JSON with map[string]any
When you don’t know the structure in advance, such as webhooks from third parties, you can decode into map[string]any:
var payload map[string]any
_ = json.Unmarshal([]byte(`{"event":"order.paid","order_id":9007199254740993}`), &payload)
fmt.Printf("%T %v\n", payload["event"], payload["event"])
fmt.Printf("%T %.0f\n", payload["order_id"], payload["order_id"])
string order.paid
float64 9007199254740992
All numbers are decoded as float64. So order_id loses precision: the original value ends in 3 but the result ends in 2. This is a very hard bug to spot.
The workaround is to call UseNumber, which keeps numbers as the string type json.Number:
dec := json.NewDecoder(strings.NewReader(`{"order_id":9007199254740993}`))
dec.UseNumber()
var payload map[string]any
_ = dec.Decode(&payload)
id, _ := payload["order_id"].(json.Number).Int64()
fmt.Println(id)
9007199254740993
If only part of the payload is dynamic, you can use json.RawMessage to defer decoding that part:
type Event struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
After reading Type, you call json.Unmarshal(e.Data, &order) with the appropriate struct. This is much safer type-wise than searching through map keys.
6. Streaming data with Encoder and Decoder
Marshal and Unmarshal work on []byte, so all data must fit in memory. json.Encoder and json.Decoder work directly with io.Writer and io.Reader. They’re ideal for HTTP bodies, files, and NDJSON data (one JSON per line).
func main() {
input := strings.NewReader(`{"id":1,"total":10000}
{"id":2,"total":25000}
{"id":3,"total":5000}`)
dec := json.NewDecoder(input)
enc := json.NewEncoder(os.Stdout)
enc.SetEscapeHTML(false)
sum := 0.0
for {
var o Order
err := dec.Decode(&o)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
log.Fatal(err)
}
sum += o.Total
}
_ = enc.Encode(map[string]float64{"sum": sum})
}
{"sum":40000}
The loop calls Decode until it hits io.EOF, reading exactly one object at a time. Encode automatically adds a newline after each value. SetEscapeHTML(false) prevents Go from converting <, >, & to \u003c, \u003e, \u0026. By default Go escapes these for safety when embedding JSON in HTML.
In an HTTP handler, you use this same pattern:
func createOrder(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // max 1 MB
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var o Order
if err := dec.Decode(&o); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(o)
}
http.MaxBytesReader limits the body size. This way clients can’t send huge payloads and drain your server’s memory.
7. Custom types
Suppose order status is stored as int in code but must be displayed as a string in JSON. Implement encoding.TextMarshaler and encoding.TextUnmarshaler:
type Status int
const (
StatusPending Status = iota
StatusPaid
StatusShipped
)
var statusNames = map[Status]string{
StatusPending: "pending",
StatusPaid: "paid",
StatusShipped: "shipped",
}
func (s Status) MarshalText() ([]byte, error) {
name, ok := statusNames[s]
if !ok {
return nil, fmt.Errorf("invalid status: %d", s)
}
return []byte(name), nil
}
func (s *Status) UnmarshalText(b []byte) error {
for k, v := range statusNames {
if v == string(b) {
*s = k
return nil
}
}
return fmt.Errorf("invalid status: %q", b)
}
When used in a struct:
type OrderStatus struct {
ID int `json:"id"`
Status Status `json:"status"`
}
data, _ := json.Marshal(OrderStatus{ID: 1001, Status: StatusPaid})
fmt.Println(string(data))
{"id":1001,"status":"paid"}
MarshalText uses a value receiver so it works with both Status and *Status. UnmarshalText must use a pointer receiver because it needs to modify the value. Using TextMarshaler instead of json.Marshaler has an added benefit: the Status type can be used as a map key, like map[Status]int.
When you need full control over the object, write MarshalJSON. Beware infinite recursion: if you call json.Marshal(o) inside with the same type, the function calls itself. The fix is to use an alias type:
func (o Order) MarshalJSON() ([]byte, error) {
type alias Order // alias doesn't have MarshalJSON method
return json.Marshal(struct {
alias
ItemCount int `json:"item_count"`
}{alias(o), len(o.Items)})
}
alias has the same fields as Order but no methods, so json.Marshal uses the default behavior. Embedded fields are “flattened” into the outer object, plus the new item_count field.
8. Writing tests
Round-trip tests—write then read back—catch bugs when someone changes a tag or custom logic. File status_test.go:
package main
import (
"bytes"
"encoding/json"
"testing"
)
func TestStatusRoundTrip(t *testing.T) {
tests := []struct {
status Status
want string
}{
{StatusPending, `{"id":1,"status":"pending"}`},
{StatusPaid, `{"id":1,"status":"paid"}`},
{StatusShipped, `{"id":1,"status":"shipped"}`},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
data, err := json.Marshal(OrderStatus{ID: 1, Status: tt.status})
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
if string(data) != tt.want {
t.Errorf("got %s, want %s", data, tt.want)
}
var back OrderStatus
if err := json.Unmarshal(data, &back); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if back.Status != tt.status {
t.Errorf("got %v, want %v", back.Status, tt.status)
}
})
}
}
func TestRejectUnknownField(t *testing.T) {
dec := json.NewDecoder(bytes.NewBufferString(`{"id":1,"coupon":"X"}`))
dec.DisallowUnknownFields()
var o OrderStatus
if err := dec.Decode(&o); err == nil {
t.Fatal("expected unknown field error, got nil")
}
}
go test ./...
ok example.com/shop 0.003s
Table-driven tests are the idiomatic Go style. Each case runs in its own t.Run, so when one fails, you immediately see which one.
9. Common errors
- Field not appearing in JSON. Almost always because the field name is lowercase (unexported). Change it to uppercase and use a tag to set the JSON name.
nullinstead of[]. Anilslice is written asnull. If the client needs an empty array, initializeItems: []string{}.- Lost precision on large numbers. Happens when decoding into
any. Use a struct with a specific type (int64),UseNumber, or tag with,string. - Type error is silently skipped. When
Unmarshalhits a*json.UnmarshalTypeError, it still fills other fields. Don’t use the struct iferr != nil. []bytebecomes base64. This is intentional. If you want plain text, usestring.time.Timefollows RFC 3339. For other formats likedd/MM/yyyy, write a custom type.- Calling
json.MarshalinsideMarshalJSONwith the same type. Stack overflow results. Use an alias type like in section 7.
Note:
encoding/jsonuses reflection, so it’s not the fastest option. For most APIs, its speed is plenty sufficient. Only when profiling shows JSON is the bottleneck should you consider a code-generation library. Go 1.25 also has an experimentalencoding/json/v2, enabled viaGOEXPERIMENT=jsonv2. This is not yet stable, so not suitable for production.
10. Conclusion
You’ve covered the main operations with encoding/json:
- Use
MarshalandUnmarshalfor small data. - Use struct tags (
omitempty,omitzero,string,-) to control output. - Use
DecoderwithDisallowUnknownFieldsandUseNumberfor safer input. - Use
EncoderandDecoderto stream data. - Use
TextMarshalerorMarshalJSONfor custom types.
The general principle is to prefer structs with explicit types over map[string]any, and always check error. You should also write round-trip tests for custom types. These three habits will help you avoid most JSON bugs in Go services.