JSON Formatter Go β€” MarshalIndent() Guide

Β·Systems EngineerΒ·Reviewed byTobias MΓΌllerΒ·Published

Use the free online JSON Formatter & Beautifier directly in your browser β€” no install required.

Try JSON Formatter & Beautifier Online β†’

When I'm working on a Go microservice and need to inspect an API response or a config file, compact JSON is the first obstacle β€” a single line with hundreds of nested fields tells me almost nothing at a glance. To format JSON in Go, the standard library gives you everything you need: json.MarshalIndent is built into encoding/json, ships with every Go installation, and requires zero third-party dependencies. This guide covers the full picture: struct tags, custom MarshalJSON() implementations, json.Indent for re-formatting raw bytes, streaming large files with json.Decoder, and when to reach for go-json for high-throughput paths, plus CLI one-liners for quick formatting in the terminal. All examples use Go 1.21+.

  • βœ“json.MarshalIndent(v, "", "\t") is standard library β€” zero dependencies, ships with every Go install.
  • βœ“Struct tags json:"field_name,omitempty" control serialization keys and omit zero-value fields from output.
  • βœ“Implement MarshalJSON() on any type to fully control its JSON representation.
  • βœ“json.Indent() re-formats already-marshaled []byte without re-parsing the struct β€” faster for raw bytes.
  • βœ“For large files (>100 MB): use json.Decoder with Token() to stream without loading all into memory.
  • βœ“go-json is a drop-in replacement 3–5Γ— faster than encoding/json for high-throughput APIs.

What is JSON Formatting?

JSON formatting β€” also called pretty-printing β€” transforms a compact, minified JSON string into a human-readable layout with consistent indentation and line breaks. The underlying data is identical; only the whitespace changes. Compact JSON is optimal for network transfer where every byte matters; formatted JSON is optimal for debugging, code review, log inspection, and config file authoring. Go's encoding/json package handles both modes with a single function call β€” toggle between compact and indented output by choosing between json.Marshal and json.MarshalIndent.

Before Β· json
After Β· json
{"service":"payments","port":8443,"workers":4}
{
	"service": "payments",
	"port": 8443,
	"workers": 4
}

json.MarshalIndent() β€” The Standard Library Approach

json.MarshalIndent lives in the encoding/json package, which is part of the Go standard library β€” no go get required. Its signature is MarshalIndent(v any, prefix, indent string) ([]byte, error): the prefix string is prepended to every output line (almost always left empty), and indent is repeated once per nesting level. Pass "\t" for tabs or " " for two spaces.

Go β€” minimal working example
package main

import (
	"encoding/json"
	"fmt"
	"log"
)

type ServiceConfig struct {
	Host       string   `json:"host"`
	Port       int      `json:"port"`
	Workers    int      `json:"workers"`
	TLSEnabled bool     `json:"tls_enabled"`
	AllowedIPs []string `json:"allowed_ips"`
}

func main() {
	cfg := ServiceConfig{
		Host:       "payments-api.internal",
		Port:       8443,
		Workers:    8,
		TLSEnabled: true,
		AllowedIPs: []string{"10.0.0.0/8", "172.16.0.0/12"},
	}

	data, err := json.MarshalIndent(cfg, "", "	")
	if err != nil {
		log.Fatalf("marshal config: %v", err)
	}
	fmt.Println(string(data))
}
// {
// 	"host": "payments-api.internal",
// 	"port": 8443,
// 	"workers": 8,
// 	"tls_enabled": true,
// 	"allowed_ips": [
// 		"10.0.0.0/8",
// 		"172.16.0.0/12"
// 	]
// }

The choice between tabs and spaces is mostly a team convention. Many Go projects prefer tabs because gofmt (which formats Go source code) uses tabs. Two-space or four-space indentation is common when the JSON is destined for a JavaScript or Python consumer. Here is the same struct with both indent styles side by side:

Go β€” tabs vs spaces
// Tab indent β€” preferred in Go-native tooling
data, _ := json.MarshalIndent(cfg, "", "	")
// {
// 	"host": "payments-api.internal",
// 	"port": 8443
// }

// Two-space indent β€” common for APIs with JS/Python consumers
data, _ = json.MarshalIndent(cfg, "", "  ")
// {
//   "host": "payments-api.internal",
//   "port": 8443
// }

// Four-space indent
data, _ = json.MarshalIndent(cfg, "", "    ")
// {
//     "host": "payments-api.internal",
//     "port": 8443
// }
Note:Use json.Marshal(v) when you need compact output β€” network payloads, cache values, or any path where binary size matters. It takes the same value argument and has the same error semantics, but produces single-line JSON without any whitespace.

Struct Tags β€” Controlling Field Names and Omitempty

Go struct tags are string literals placed after field declarations that tellencoding/jsonhow to serialize each field. There are three key directives: json:"name" renames the field in output, omitempty omits the field when it holds the zero value for its type, and json:"-" excludes the field entirely β€” useful for passwords, internal identifiers, or fields that must never leave the service boundary.

Go β€” struct tags for an API response
type UserProfile struct {
	ID          string  `json:"id"`
	Email       string  `json:"email"`
	DisplayName string  `json:"display_name,omitempty"`  // omit if empty string
	AvatarURL   *string `json:"avatar_url,omitempty"`    // omit if nil pointer
	IsAdmin     bool    `json:"is_admin,omitempty"`      // omit if false
	passwordHash string                                   // unexported β€” auto excluded
}

// User with all optional fields populated
full := UserProfile{
	ID: "usr_7b3c", Email: "ops@example.com",
	DisplayName: "Ops Team", IsAdmin: true,
}
// {
//   "id": "usr_7b3c",
//   "email": "ops@example.com",
//   "display_name": "Ops Team",
//   "is_admin": true
// }

// User with no optional fields β€” they are omitted entirely
minimal := UserProfile{ID: "usr_2a91", Email: "dev@example.com"}
// {
//   "id": "usr_2a91",
//   "email": "dev@example.com"
// }

The json:"-" tag is the right choice for fields that must be unconditionally excluded regardless of their value β€” typically secrets, internal tracking fields, or data that is correct in memory but must not be serialized to any external system.

Go β€” excluding sensitive fields
type AuthToken struct {
	TokenID      string `json:"token_id"`
	Subject      string `json:"sub"`
	IssuedAt     int64  `json:"iat"`
	ExpiresAt    int64  `json:"exp"`
	SigningKey    []byte `json:"-"`   // never serialized
	RefreshToken string `json:"-"`   // never serialized
}

tok := AuthToken{
	TokenID: "tok_8f2a", Subject: "usr_7b3c",
	IssuedAt: 1741614120, ExpiresAt: 1741617720,
	SigningKey: []byte("secret"), RefreshToken: "rt_9e4f",
}
data, _ := json.MarshalIndent(tok, "", "  ")
// {
//   "token_id": "tok_8f2a",
//   "sub": "usr_7b3c",
//   "iat": 1741614120,
//   "exp": 1741617720
// }
// SigningKey and RefreshToken never appear
Note:Unexported (lowercase) struct fields are always excluded by encoding/json regardless of any tags. You do not need to add json:"-" to unexported fields β€” the exclusion is automatic and cannot be overridden.

Custom MarshalJSON() β€” Handling Non-Standard Types

Any Go type can implement the json.Marshaler interface by defining a MarshalJSON() ([]byte, error) method. When encoding/json encounters such a type, it calls the method instead of its default reflection-based marshaling. This is the canonical Go pattern for domain types that need a specific wire representation β€” monetary values, status enums, custom time formats, or any type that stores data differently from how it should be serialized.

Custom Type β€” Money with Cents-to-Decimal Conversion

Go β€” custom MarshalJSON for Money
package main

import (
	"encoding/json"
	"fmt"
	"log"
)

type Money struct {
	Amount   int64  // stored in cents to avoid floating-point drift
	Currency string
}

func (m Money) MarshalJSON() ([]byte, error) {
	return json.Marshal(struct {
		Amount   float64 `json:"amount"`
		Currency string  `json:"currency"`
		Display  string  `json:"display"`
	}{
		Amount:   float64(m.Amount) / 100,
		Currency: m.Currency,
		Display:  fmt.Sprintf("%s %.2f", m.Currency, float64(m.Amount)/100),
	})
}

type Invoice struct {
	ID       string `json:"id"`
	Subtotal Money  `json:"subtotal"`
	Tax      Money  `json:"tax"`
	Total    Money  `json:"total"`
}

func main() {
	inv := Invoice{
		ID:       "inv_9a2f91bc",
		Subtotal: Money{Amount: 19900, Currency: "USD"},
		Tax:      Money{Amount: 1592, Currency: "USD"},
		Total:    Money{Amount: 21492, Currency: "USD"},
	}
	data, err := json.MarshalIndent(inv, "", "  ")
	if err != nil {
		log.Fatalf("marshal invoice: %v", err)
	}
	fmt.Println(string(data))
}
// {
//   "id": "inv_9a2f91bc",
//   "subtotal": { "amount": 199, "currency": "USD", "display": "USD 199.00" },
//   "tax":      { "amount": 15.92, "currency": "USD", "display": "USD 15.92" },
//   "total":    { "amount": 214.92, "currency": "USD", "display": "USD 214.92" }
// }

Status Enum β€” String Representation

Go β€” custom MarshalJSON for status enum
type OrderStatus int

const (
	StatusPending OrderStatus = iota
	StatusPaid
	StatusShipped
	StatusCancelled
)

var orderStatusNames = map[OrderStatus]string{
	StatusPending:   "pending",
	StatusPaid:      "paid",
	StatusShipped:   "shipped",
	StatusCancelled: "cancelled",
}

func (s OrderStatus) MarshalJSON() ([]byte, error) {
	name, ok := orderStatusNames[s]
	if !ok {
		return nil, fmt.Errorf("unknown order status: %d", s)
	}
	return json.Marshal(name)
}

type Order struct {
	ID     string      `json:"id"`
	Status OrderStatus `json:"status"`
}

o := Order{ID: "ord_3c7f", Status: StatusShipped}
data, _ := json.MarshalIndent(o, "", "  ")
// {
//   "id": "ord_3c7f",
//   "status": "shipped"
// }
Note:Always implement both MarshalJSON() and UnmarshalJSON() together. If you only implement marshaling, round-tripping the type through JSON (serialize β†’ store β†’ deserialize) will silently lose structure or return the wrong type. The pair forms a contract that the type can survive a JSON round-trip.

UUID β€” Serialize as String

Go's standard library has no UUID type. The most common choice is github.com/google/uuid, which already implements MarshalJSON() and serializes as a quoted RFC 4122 string. If you use a raw [16]byte or a custom ID type, implement the interface yourself to avoid base64-encoded binary blobs in your JSON output.

Go 1.21+ β€” UUID serialization
import (
    "encoding/json"
    "fmt"

    "github.com/google/uuid"
)

type AuditEvent struct {
    EventID   uuid.UUID `json:"event_id"`   // serializes as "550e8400-e29b-41d4-a716-446655440000"
    SessionID uuid.UUID `json:"session_id"`
    Action    string    `json:"action"`
    ActorID   string    `json:"actor_id"`
    OccuredAt string    `json:"occurred_at"`
}

event := AuditEvent{
    EventID:   uuid.New(),
    SessionID: uuid.MustParse("550e8400-e29b-41d4-a716-446655440000"),
    Action:    "user.password_changed",
    ActorID:   "usr_7f3a91bc",
    OccuredAt: "2026-03-10T14:22:00Z",
}

data, _ := json.MarshalIndent(event, "", "  ")
fmt.Println(string(data))
// {
//   "event_id": "a4b2c1d0-...",
//   "session_id": "550e8400-e29b-41d4-a716-446655440000",
//   "action": "user.password_changed",
//   "actor_id": "usr_7f3a91bc",
//   "occurred_at": "2026-03-10T14:22:00Z"
// }
Note:If you store UUIDs as [16]byte without a custom type, encoding/json encodes them as a base64 string β€” e.g. "VQ6EAOKbQdSnFkRmVUQAAA==". Always use a proper UUID type or implement MarshalJSON() to emit the canonical hyphenated string format.

json.MarshalIndent() Parameters Reference

The function signature is func MarshalIndent(v any, prefix, indent string) ([]byte, error). Both prefix and indent are string literals β€” there is no numeric shorthand like Python's indent=4.

Parameter
Type
Description
v
any
The value to marshal β€” struct, map, slice, or primitive
prefix
string
String prepended to each output line (usually "")
indent
string
String used for each indentation level ("\t" or " ")

Common struct tag options:

Tag
Effect
json:"name"
Rename field to name in JSON output
json:"name,omitempty"
Rename + omit if zero value (nil, "", 0, false)
json:"-"
Always exclude this field from JSON output
json:",string"
Encode number or bool as a JSON string value

json.Indent() β€” Re-formatting Existing JSON Bytes

When you already have a []byte of JSON β€” say from an HTTP response body, a Postgres jsonb column, or a file read with os.ReadFile β€” you do not need to define a struct and unmarshal before you can pretty-print. json.Indent directly reformats the raw bytes by writing indented output into a bytes.Buffer.

Go β€” json.Indent on raw bytes
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"log"
)

func main() {
	// Simulating a raw JSON payload from an upstream service
	raw := []byte(`{"trace_id":"tr_9a2f","service":"checkout","latency_ms":342,"error":null}`)

	var buf bytes.Buffer
	if err := json.Indent(&buf, raw, "", "	"); err != nil {
		log.Fatalf("indent: %v", err)
	}
	fmt.Println(buf.String())
}
// {
// 	"trace_id": "tr_9a2f",
// 	"service": "checkout",
// 	"latency_ms": 342,
// 	"error": null
// }

A common pattern I use in microservices is to call json.Indent before writing to structured logs β€” it adds negligible overhead and makes log entries far easier to read during an incident. The function is particularly useful for logging HTTP responses, pretty-printing stored JSON strings, and format-on-read pipelines where the struct definition is unavailable.

Go β€” json.Indent for debug logging
func logResponse(logger *slog.Logger, statusCode int, body []byte) {
	var pretty bytes.Buffer
	if err := json.Indent(&pretty, body, "", "  "); err != nil {
		// Body is not valid JSON β€” log raw
		logger.Debug("upstream response", "status", statusCode, "body", string(body))
		return
	}
	logger.Debug("upstream response", "status", statusCode, "body", pretty.String())
}
Warning:json.Indent does NOT fully validate the JSON beyond what is structurally required to insert whitespace. For full syntax validation, call json.Valid(data) first and handle the false case before attempting to indent.

Format JSON from a File and HTTP Response

Two of the most common real-world scenarios in Go services are formatting JSON read from a file on disk (config files, fixture data, migration seeds) and pretty-printing HTTP response bodies for debug logging or test assertions. Both follow the same pattern: read bytes, call json.Indent or unmarshal then json.MarshalIndent, write back or log.

Read File β†’ Format β†’ Write Back

Go β€” format JSON file in place
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"log"
	"os"
)

func formatJSONFile(path string) error {
	data, err := os.ReadFile(path)
	if err != nil {
		return fmt.Errorf("read %s: %w", path, err)
	}

	if !json.Valid(data) {
		return fmt.Errorf("invalid JSON in %s", path)
	}

	var buf bytes.Buffer
	if err := json.Indent(&buf, data, "", "	"); err != nil {
		return fmt.Errorf("indent %s: %w", path, err)
	}

	if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil {
		return fmt.Errorf("write %s: %w", path, err)
	}
	return nil
}

func main() {
	if err := formatJSONFile("config/database.json"); err != nil {
		log.Fatalf("format config: %v", err)
	}
	fmt.Println("config/database.json formatted successfully")
}

HTTP Response β†’ Decode β†’ Pretty-Print for Debug Logging

Go β€” format HTTP response for debug log
package main

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

type HealthResponse struct {
	Status    string            `json:"status"`
	Version   string            `json:"version"`
	Checks    map[string]string `json:"checks"`
	UptimeSec int64             `json:"uptime_seconds"`
}

func main() {
	resp, err := http.Get("https://api.payments.internal/v2/health")
	if err != nil {
		log.Fatalf("health check: %v", err)
	}
	defer resp.Body.Close()

	var result HealthResponse
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		log.Fatalf("decode health response: %v", err)
	}

	pretty, err := json.MarshalIndent(result, "", "  ")
	if err != nil {
		log.Fatalf("marshal health response: %v", err)
	}
	fmt.Printf("Health check (%d):
%s
", resp.StatusCode, string(pretty))
}
// Health check (200):
// {
//   "status": "ok",
//   "version": "1.4.2",
//   "checks": {
//     "database": "ok",
//     "cache": "ok",
//     "queue": "degraded"
//   },
//   "uptime_seconds": 172800
// }

Raw Response Body β†’ json.Indent (No Struct Required)

Go β€” format raw HTTP body with io.ReadAll
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
)

func main() {
	resp, err := http.Get("https://api.payments.internal/v2/health")
	if err != nil {
		log.Fatalf("request: %v", err)
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatalf("read body: %v", err)
	}

	var buf bytes.Buffer
	if err := json.Indent(&buf, body, "", "  "); err != nil {
		log.Fatalf("indent: %v", err)
	}
	fmt.Println(buf.String())
}

Pretty Print JSON from an HTTP Response in Go

The two approaches above cover the most common cases: decode into a typed struct then call json.MarshalIndent (best when you need to validate or inspect fields), or read the raw body bytes with io.ReadAll and call json.Indent directly (best for quick debug logging when you do not have a struct definition handy). The raw-bytes approach is simpler but does not give you Go type safety or field access β€” it is purely a display transformation. Both approaches handle large response bodies correctly as long as the full body fits in memory.

Go β€” two patterns side by side
// Pattern A: typed decode β†’ MarshalIndent
// Use when you need to inspect or validate specific fields
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
pretty, _ := json.MarshalIndent(result, "", "  ")
fmt.Println(string(pretty))

// Pattern B: raw bytes β†’ json.Indent
// Use for quick debug logging β€” no struct definition required
body, _ := io.ReadAll(resp.Body)
var buf bytes.Buffer
json.Indent(&buf, body, "", "  ")
fmt.Println(buf.String())

Command-Line JSON Formatting in Go Projects

Sometimes you need to format a JSON payload right in the terminal without writing a Go program. These one-liners are the ones I keep in muscle memory during development and incident response.

bash β€” pipe JSON to Python's built-in formatter
echo '{"service":"payments","port":8443,"workers":4}' | python3 -m json.tool
# {
#     "service": "payments",
#     "port": 8443,
#     "workers": 4
# }
bash β€” pipe to jq for full-featured formatting and filtering
# Format only
cat api-response.json | jq .

# Extract a nested field
cat api-response.json | jq '.checks.database'

# Filter an array
cat audit-log.json | jq '.[] | select(.severity == "error")'
bash β€” pipe to a minimal Go main.go via stdin
# main.go: reads stdin, formats, writes stdout
cat <<'EOF' > /tmp/fmt.go
package main
import ("bytes";"encoding/json";"io";"os")
func main() {
    b,_:=io.ReadAll(os.Stdin)
    var buf bytes.Buffer
    json.Indent(&buf,b,"","  ")
    os.Stdout.Write(buf.Bytes())
}
EOF
echo '{"port":8080,"debug":true}' | go run /tmp/fmt.go
Note:gofmt formats Go source code, not JSON. Do not pipe JSON files through gofmt β€” it will either error or produce unrecognizable output. Use jq . or python3 -m json.tool for JSON files.

High-Performance Alternative β€” go-json

For the vast majority of Go services, encoding/json is fast enough. But if JSON marshaling shows up in your profiler β€” common in high-throughput REST APIs or services that emit large structured log lines on every request β€” the go-json library is a drop-in replacement that is 3–5Γ— faster with identical API surface.

bash β€” install go-json
go get github.com/goccy/go-json
Go β€” swap encoding/json for go-json with one import change
package main

import (
	// Replace this:
	// "encoding/json"

	// With this β€” identical API, no other code changes:
	json "github.com/goccy/go-json"

	"fmt"
	"log"
)

type AuditEvent struct {
	RequestID string `json:"request_id"`
	UserID    string `json:"user_id"`
	Action    string `json:"action"`
	ResourceID string `json:"resource_id"`
	IPAddress  string `json:"ip_address"`
	DurationMs int    `json:"duration_ms"`
}

func main() {
	event := AuditEvent{
		RequestID: "req_7d2e91", UserID: "usr_4421",
		Action: "invoice.download", ResourceID: "inv_9a2f",
		IPAddress: "203.0.113.45", DurationMs: 23,
	}
	data, err := json.MarshalIndent(event, "", "  ")
	if err != nil {
		log.Fatalf("marshal: %v", err)
	}
	fmt.Println(string(data))
	// Output is identical to encoding/json β€” only speed differs
}

github.com/bytedance/sonic is the fastest Go JSON library available, but it only runs on amd64 and arm64 (it uses JIT compilation). Use go-json when you need a portable drop-in; reach for sonic when you are on a known architecture and need every microsecond in a hot path.

Working with Large JSON Files

json.MarshalIndent and json.Indent both require the entire payload to be in memory. For files above 100 MB β€” data exports, audit logs, Kafka consumer payloads β€” use json.Decoder to stream the input and process records one at a time.

Streaming a Large JSON Array with json.Decoder

Go β€” stream large JSON array without loading into memory
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"os"
)

type AuditEvent struct {
	RequestID  string `json:"request_id"`
	UserID     string `json:"user_id"`
	Action     string `json:"action"`
	Severity   string `json:"severity"`
	DurationMs int    `json:"duration_ms"`
}

func processAuditLog(path string) error {
	file, err := os.Open(path)
	if err != nil {
		return fmt.Errorf("open %s: %w", path, err)
	}
	defer file.Close()

	dec := json.NewDecoder(file)

	// Read the opening '['
	if _, err := dec.Token(); err != nil {
		return fmt.Errorf("read opening token: %w", err)
	}

	var processed int
	for dec.More() {
		var event AuditEvent
		if err := dec.Decode(&event); err != nil {
			return fmt.Errorf("decode event %d: %w", processed, err)
		}
		// Process one event at a time β€” constant memory usage
		if event.Severity == "error" {
			fmt.Printf("[ERROR] %s: %s (%dms)
", event.UserID, event.Action, event.DurationMs)
		}
		processed++
	}
	fmt.Printf("Processed %d audit events
", processed)
	return nil
}

func main() {
	if err := processAuditLog("audit-2026-03.json"); err != nil {
		log.Fatalf("process audit log: %v", err)
	}
}

NDJSON β€” One JSON Object Per Line

Go β€” process NDJSON log stream line by line
package main

import (
	"bufio"
	"encoding/json"
	"fmt"
	"log"
	"os"
)

type LogLine struct {
	Timestamp  string `json:"ts"`
	Level      string `json:"level"`
	Service    string `json:"service"`
	Message    string `json:"msg"`
	TraceID    string `json:"trace_id"`
	DurationMs int    `json:"duration_ms,omitempty"`
}

func main() {
	file, err := os.Open("service-2026-03.ndjson")
	if err != nil {
		log.Fatalf("open log: %v", err)
	}
	defer file.Close()

	scanner := bufio.NewScanner(file)
	scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 1 MB per line

	for scanner.Scan() {
		var line LogLine
		if err := json.Unmarshal(scanner.Bytes(), &line); err != nil {
			continue // skip malformed lines
		}
		if line.Level == "error" {
			fmt.Printf("%s [%s] %s trace=%s
",
				line.Timestamp, line.Service, line.Message, line.TraceID)
		}
	}
	if err := scanner.Err(); err != nil {
		log.Fatalf("scan: %v", err)
	}
}
Note:Switch to streaming when the file exceeds 100 MB or when processing unbounded streams (Kafka consumers, log pipelines, S3 object reads). Loading a 500 MB JSON file with os.ReadFile will allocate that entire buffer on the heap, trigger GC pressure, and may cause OOM on memory-constrained containers.

Common Mistakes

❌ Ignoring the error return from MarshalIndent

Problem: Discarding the error with _ means a non-serializable value (a channel, a function, a complex number) silently produces nil output or panics downstream when you call string(nil).

Fix: Always check the error. If you are marshaling a type that should always succeed, a panicking log.Fatalf is better than silent data loss.

Before Β· Go
After Β· Go
data, _ := json.MarshalIndent(payload, "", "  ")
fmt.Println(string(data)) // empty string if marshal failed
data, err := json.MarshalIndent(payload, "", "  ")
if err != nil {
    log.Fatalf("marshal payload: %v", err)
}
fmt.Println(string(data))
❌ Using fmt.Println instead of os.Stdout.Write for binary output

Problem: fmt.Println(string(data)) appends a newline character after the JSON, which corrupts pipelines that treat the output as raw bytes β€” for example, when piping to jq or writing to a binary protocol.

Fix: Use os.Stdout.Write(data) for binary-clean output. If you need a trailing newline for human display, append it explicitly.

Before Β· Go
After Β· Go
data, _ := json.MarshalIndent(cfg, "", "  ")
fmt.Println(string(data)) // adds an extra newline at the end
data, _ := json.MarshalIndent(cfg, "", "  ")
os.Stdout.Write(data)
os.Stdout.Write([]byte("
")) // explicit newline only when needed
❌ Forgetting omitempty on pointer fields

Problem: Without omitempty, a nil *string or *int pointer serializes as "field": null. This exposes internal field names and can break strict JSON schema validators on the consumer side.

Fix: Add omitempty to pointer fields you want absent (not null) in the output. A nil *T with omitempty produces no key at all in the JSON.

Before Β· Go
After Β· Go
type WebhookPayload struct {
    EventID   string  `json:"event_id"`
    ErrorMsg  *string `json:"error_msg"`  // appears as null when nil
}
// {"event_id":"evt_3c7f","error_msg":null}
type WebhookPayload struct {
    EventID   string  `json:"event_id"`
    ErrorMsg  *string `json:"error_msg,omitempty"`  // omitted when nil
}
// {"event_id":"evt_3c7f"}
❌ Using map[string]interface{} instead of structs

Problem: Unmarshaling into map[string]any loses type information, requires manual type assertions, and produces non-deterministic key order β€” making JSON diffs and log comparisons harder.

Fix: Define a struct with proper json tags. Structs are type-safe, marshal faster, produce deterministic field order matching the struct definition, and make the code self-documenting.

Before Β· Go
After Β· Go
var result map[string]any
json.Unmarshal(body, &result)
port := result["port"].(float64) // type assertion required, panics if wrong type
type ServiceStatus struct {
    Service string `json:"service"`
    Port    int    `json:"port"`
    Healthy bool   `json:"healthy"`
}
var result ServiceStatus
json.Unmarshal(body, &result)
port := result.Port // typed, safe, fast

encoding/json vs Alternatives β€” Quick Comparison

Method
Pretty Output
Valid JSON
Custom Types
Streaming
Requires Install
json.MarshalIndent
βœ“
βœ“
βœ“ via MarshalJSON
βœ—
No (stdlib)
json.Indent
βœ“
βœ“
N/A (bytes only)
βœ—
No (stdlib)
json.Encoder
βœ— (compact)
βœ“
βœ“ via MarshalJSON
βœ“
No (stdlib)
go-json
βœ“
βœ“
βœ“
βœ“
go get
sonic
βœ“
βœ“
βœ“
βœ“
go get (amd64/arm64)
jq (CLI)
βœ“
βœ“
N/A
βœ“
System install

Use json.MarshalIndent for any case where you control the struct definition and need formatted output β€” config files, debug logging, test fixtures, and API response logging. Use json.Indent when you already have raw bytes and just need whitespace added without a round-trip through Go types. Switch to go-json or sonic only after profiling confirms that JSON marshaling is a measurable bottleneck β€” for most services, the standard library is more than sufficient.

Frequently Asked Questions

How do I pretty print JSON in Go?

Call json.MarshalIndent(v, "", "\t") from the encoding/json package β€” the second argument is a per-line prefix (usually empty) and the third is the per-level indent. Pass "\t" for tabs or " " for two spaces. No external library is required; encoding/json ships with the Go standard library.

Go
package main

import (
	"encoding/json"
	"fmt"
	"log"
)

func main() {
	config := map[string]any{
		"service": "payments-api",
		"port":    8443,
		"region":  "us-east-1",
	}
	data, err := json.MarshalIndent(config, "", "	")
	if err != nil {
		log.Fatalf("marshal: %v", err)
	}
	fmt.Println(string(data))
	// {
	// 	"port": 8443,
	// 	"region": "us-east-1",
	// 	"service": "payments-api"
	// }
}

What is the difference between json.Marshal and json.MarshalIndent?

json.Marshal produces compact single-line JSON with no whitespace β€” ideal for network transfer where every byte counts. json.MarshalIndent takes two extra string parameters (prefix and indent) and produces indented, human-readable output. Both functions accept the same value types and return ([]byte, error). The only cost of MarshalIndent is slightly more output bytes and a negligible amount of extra CPU for inserting whitespace.

Go
import "encoding/json"

type HealthCheck struct {
	Status  string `json:"status"`
	Version string `json:"version"`
	Uptime  int    `json:"uptime_seconds"`
}

h := HealthCheck{Status: "ok", Version: "1.4.2", Uptime: 86400}

compact, _ := json.Marshal(h)
// {"status":"ok","version":"1.4.2","uptime_seconds":86400}

pretty, _ := json.MarshalIndent(h, "", "  ")
// {
//   "status": "ok",
//   "version": "1.4.2",
//   "uptime_seconds": 86400
// }

How do I format a JSON []byte without unmarshaling the struct?

Use json.Indent(&buf, src, "", "\t"). This function takes an existing []byte of JSON and writes the indented version into a bytes.Buffer β€” no struct definition needed, no type assertion, no round-trip through Go types. It is the fastest option when you already have raw JSON bytes, such as from an HTTP response body or a database column.

Go
import (
	"bytes"
	"encoding/json"
	"fmt"
	"log"
)

raw := []byte(`{"endpoint":"/api/v2/invoices","page":1,"per_page":50,"total":312}`)

var buf bytes.Buffer
if err := json.Indent(&buf, raw, "", "	"); err != nil {
	log.Fatalf("indent: %v", err)
}
fmt.Println(buf.String())
// {
// 	"endpoint": "/api/v2/invoices",
// 	"page": 1,
// 	"per_page": 50,
// 	"total": 312
// }

Why does json.MarshalIndent return an error?

encoding/json returns an error when the value cannot be represented as JSON. The most common causes are: marshaling a channel, a function, or a complex number (these have no JSON equivalent); a struct that implements MarshalJSON() and returns an error; or a map with non-string keys. Importantly, marshaling a struct with an unexported or nil pointer field does NOT cause an error β€” those are simply omitted.

Go
import (
	"encoding/json"
	"fmt"
)

// This will return an error β€” channels are not JSON-serializable
ch := make(chan int)
_, err := json.MarshalIndent(ch, "", "	")
fmt.Println(err)
// json: unsupported type: chan int

// This is fine β€” nil pointer fields with omitempty are omitted silently
type Profile struct {
	ID     string  `json:"id"`
	Avatar *string `json:"avatar,omitempty"`
}
p := Profile{ID: "usr_7b3c"}
data, _ := json.MarshalIndent(p, "", "  ")
// {"id": "usr_7b3c"}  β€” avatar omitted, no error

How do I exclude a field from JSON output in Go?

There are three ways. First, use the json:"-" struct tag β€” the field is always excluded regardless of its value. Second, use omitempty β€” the field is excluded only when it holds the zero value for its type (nil pointer, empty string, 0, false). Third, unexported (lowercase) fields are automatically excluded by encoding/json without any tag needed.

Go
type PaymentMethod struct {
	ID           string  `json:"id"`
	Last4        string  `json:"last4"`
	ExpiryMonth  int     `json:"expiry_month"`
	ExpiryYear   int     `json:"expiry_year"`
	CVV          string  `json:"-"`                    // always excluded
	BillingName  string  `json:"billing_name,omitempty"` // excluded if empty
	internalRef  string                                 // unexported β€” auto excluded
}

pm := PaymentMethod{
	ID: "pm_9f3a", Last4: "4242",
	ExpiryMonth: 12, ExpiryYear: 2028,
	CVV: "123", internalRef: "stripe:pm_9f3a",
}
data, _ := json.MarshalIndent(pm, "", "  ")
// CVV, BillingName (empty), and internalRef do not appear in output

How do I handle time.Time in JSON marshaling?

encoding/json marshals time.Time to RFC3339Nano format by default (e.g. "2026-03-10T14:22:00Z"), which is ISO 8601 compatible. If you need a different format β€” such as Unix epoch integers for a legacy API, or a custom date-only string β€” implement MarshalJSON() on a wrapper type that embeds time.Time and returns the format you need.

Go
import (
	"encoding/json"
	"fmt"
	"time"
)

// Default behavior β€” RFC3339Nano, no custom code needed
type AuditEvent struct {
	Action    string    `json:"action"`
	OccurredAt time.Time `json:"occurred_at"`
}

e := AuditEvent{
	Action:    "invoice.paid",
	OccurredAt: time.Date(2026, 3, 10, 14, 22, 0, 0, time.UTC),
}
data, _ := json.MarshalIndent(e, "", "  ")
// {
//   "action": "invoice.paid",
//   "occurred_at": "2026-03-10T14:22:00Z"
// }

// Custom: Unix timestamp as integer
type UnixTime struct{ time.Time }

func (u UnixTime) MarshalJSON() ([]byte, error) {
	return []byte(fmt.Sprintf("%d", u.Unix())), nil
}
Also available in:PythonJavaScriptBash
JO
James OkaforSystems Engineer

James is a systems engineer and Go enthusiast who focuses on high-performance microservices, command-line tooling, and infrastructure automation. He enjoys the simplicity and explicitness of Go and writes about building fast, reliable backend systems. When not coding he explores distributed systems concepts and contributes to open-source Go libraries.

TM
Tobias MΓΌllerTechnical Reviewer

Tobias is a platform engineer who builds developer tooling and internal infrastructure in Go. He has authored several open-source CLI tools and contributes to the Go toolchain ecosystem. He writes about the cobra and urfave/cli frameworks, cross-platform binary distribution, configuration management, and the patterns that make Go an ideal language for building reliable, self-contained command-line utilities.