encoding/json Source — Tasks¶
20 hands-on tasks for working with — and reading — encoding/json. Targeted at Go 1.22+. Each task has acceptance criteria; the reference solutions compile.
Skip nothing, but skip order: junior → middle → senior → staff. The staff tasks assume the senior ones are easy.
Task 1: Marshal a struct to JSON¶
Goal: Encode a Go struct to JSON and print it. Difficulty: Junior Skills: json.Marshal, struct tags, basic types.
Steps¶
- Define a struct
UserwithName string,Age int,Active bool. - Construct a value and call
json.Marshal. - Print the result with
fmt.Println(string(b)).
Acceptance criteria¶
- Program prints
{"Name":"...","Age":...,"Active":...}. - No errors.
Hint
`json.Marshal` returns `([]byte, error)`. Always check the error.Reference solution
Task 2: Unmarshal JSON into a struct¶
Goal: Decode JSON into a typed Go value. Difficulty: Junior Skills: json.Unmarshal.
Steps¶
- Define a
Userstruct matching the JSON shape. - Call
json.Unmarshal([]byte(...), &u). - Print individual fields.
Acceptance criteria¶
- Decoded fields match the JSON.
- Unknown JSON fields are silently ignored (default behavior).
Hint
`Unmarshal` needs a pointer destination.Reference solution
Task 3: Struct tags with omitempty and -¶
Goal: Control field naming, omission, and exclusion. Difficulty: Junior Skills: Struct tags.
Steps¶
- Define a struct with three fields: an always-included
id, anemailthat should omit when empty, and apasswordthat must never appear. - Marshal twice — once with
emailset, once empty — and observe.
Acceptance criteria¶
passwordnever appears.- When email is empty, the key is missing.
- When email is set, the key appears.
Hint
Tags: `json:"id"`, `json:"email,omitempty"`, `json:"-"`.Reference solution
package main
import (
"encoding/json"
"fmt"
)
type Account struct {
ID int `json:"id"`
Email string `json:"email,omitempty"`
Password string `json:"-"`
}
func main() {
withEmail, _ := json.Marshal(Account{ID: 1, Email: "a@b.com", Password: "secret"})
noEmail, _ := json.Marshal(Account{ID: 2, Password: "secret"})
fmt.Println(string(withEmail))
fmt.Println(string(noEmail))
}
Task 4: Locate Marshal in source¶
Goal: Open $GOROOT/src/encoding/json/encode.go and find the Marshal function entry point. Difficulty: Junior Skills: Reading stdlib source.
Steps¶
- Run
go env GOROOTto find the source root. - Open
$GOROOT/src/encoding/json/encode.go. - Find
func Marshal(v any) ([]byte, error). - Note the line number, and identify the helper it calls (
newEncodeState,marshal, etc.).
Acceptance criteria¶
- You can quote the
Marshalfunction signature. - You can name the next-level function it dispatches to.
Reference solution
`Marshal` calls `newEncodeState()` to get a pooled `encodeState`, then `e.marshal(v, encOpts{escapeHTML: true})`. The pooled state is returned to `encodeStatePool` via `defer`. The actual encoding work is dispatched to `valueEncoder(rv)` based on `reflect.Value`.Task 5: Custom Marshaler / Unmarshaler for a Duration¶
Goal: Implement json.Marshaler and json.Unmarshaler on a custom type so that a duration round-trips as a string like "1h30m". Difficulty: Middle Skills: Interfaces, time.Duration, time.ParseDuration.
Steps¶
- Define
type Duration time.Duration. - Implement
MarshalJSON() ([]byte, error)producing"1h30m". - Implement
UnmarshalJSON(b []byte) errorparsing the same. - Test round-trip.
Acceptance criteria¶
- Marshaling produces
"1h30m"not5400000000000. - Unmarshaling parses back to the same
Durationvalue.
Hint
`json.Marshal` checks for `Marshaler` *before* falling back to reflection. The method must be on the same type (pointer or value) you marshal.Reference solution
package main
import (
"encoding/json"
"fmt"
"strconv"
"time"
)
type Duration time.Duration
func (d Duration) MarshalJSON() ([]byte, error) {
return []byte(strconv.Quote(time.Duration(d).String())), nil
}
func (d *Duration) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
parsed, err := time.ParseDuration(s)
if err != nil {
return err
}
*d = Duration(parsed)
return nil
}
type Config struct {
Timeout Duration `json:"timeout"`
}
func main() {
c := Config{Timeout: Duration(90 * time.Minute)}
b, _ := json.Marshal(c)
fmt.Println(string(b))
var back Config
_ = json.Unmarshal(b, &back)
fmt.Println(time.Duration(back.Timeout))
}
Task 6: Stream a large JSON array¶
Goal: Use Decoder.Token to consume the opening [, then Decoder.Decode per element. This avoids holding the whole array in memory. Difficulty: Middle Skills: json.Decoder, streaming.
Steps¶
- Construct a JSON file or strings.Reader containing
[{...},{...},...]. - Create a
*Decoder. - Read one token; assert it's
json.Delim('['). - Loop while
d.More():d.Decode(&item), process. - Read the closing
].
Acceptance criteria¶
- Memory does not scale with array length.
- Each element is processed individually.
Hint
`Delim` is its own type; type-assert it.Reference solution
package main
import (
"encoding/json"
"fmt"
"strings"
)
type Item struct {
ID int `json:"id"`
}
func main() {
r := strings.NewReader(`[{"id":1},{"id":2},{"id":3}]`)
dec := json.NewDecoder(r)
// Opening [
tok, err := dec.Token()
if err != nil {
panic(err)
}
if d, ok := tok.(json.Delim); !ok || d != '[' {
panic("expected [")
}
for dec.More() {
var it Item
if err := dec.Decode(&it); err != nil {
panic(err)
}
fmt.Println("got", it.ID)
}
// Closing ]
_, _ = dec.Token()
}
Task 7: RawMessage for delayed polymorphic parsing¶
Goal: Use json.RawMessage to peek at a discriminator field and decode the payload only after. Difficulty: Middle Skills: json.RawMessage, two-phase decode.
Steps¶
- Define an envelope:
Type string,Payload json.RawMessage. - Unmarshal once into the envelope.
- Switch on
Type; unmarshalPayloadinto the right concrete type.
Acceptance criteria¶
- Heterogeneous payloads decode correctly.
- The
Payloadbytes are parsed once total.
Hint
`RawMessage` is `[]byte`; pass `&rm` to `Unmarshal`.Reference solution
package main
import (
"encoding/json"
"fmt"
)
type Envelope struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
type LoginEvent struct {
User string `json:"user"`
}
type PurchaseEvent struct {
Item string `json:"item"`
Price float64 `json:"price"`
}
func main() {
data := []byte(`{"type":"login","payload":{"user":"aybek"}}`)
var env Envelope
if err := json.Unmarshal(data, &env); err != nil {
panic(err)
}
switch env.Type {
case "login":
var ev LoginEvent
_ = json.Unmarshal(env.Payload, &ev)
fmt.Printf("login: %s\n", ev.User)
case "purchase":
var ev PurchaseEvent
_ = json.Unmarshal(env.Payload, &ev)
fmt.Printf("purchase: %s for %.2f\n", ev.Item, ev.Price)
}
}
Task 8: DisallowUnknownFields for schema drift detection¶
Goal: Reject payloads with unexpected fields, catching schema drift at the boundary. Difficulty: Middle Skills: Decoder.DisallowUnknownFields.
Steps¶
- Build a
*Decoder. - Call
dec.DisallowUnknownFields(). - Decode known-good and known-bad payloads.
Acceptance criteria¶
- Known-good payload decodes successfully.
- Payload with extra field returns an error mentioning the unknown field.
Hint
This only works via `Decoder`, not `json.Unmarshal`.Reference solution
package main
import (
"encoding/json"
"fmt"
"strings"
)
type Request struct {
Name string `json:"name"`
}
func decode(s string) error {
dec := json.NewDecoder(strings.NewReader(s))
dec.DisallowUnknownFields()
var r Request
return dec.Decode(&r)
}
func main() {
fmt.Println(decode(`{"name":"ok"}`)) // <nil>
fmt.Println(decode(`{"name":"ok","unexpected":"surprise"}`)) // json: unknown field "unexpected"
}
Task 9: json.Number for big integers¶
Goal: Preserve precision of integers larger than 2^53 (which lose precision as float64). Difficulty: Middle Skills: Decoder.UseNumber, json.Number.
Steps¶
- Build a
*Decoder; callUseNumber(). - Decode into
map[string]json.Numberor a struct withjson.Numberfield. - Convert via
.Int64()or.Float64()only when you need the typed value.
Acceptance criteria¶
- A 19-digit integer round-trips without precision loss.
- Using default decoding (no
UseNumber) loses precision (demonstrate).
Hint
`json.Number` is just a `string`; methods convert on demand.Reference solution
package main
import (
"encoding/json"
"fmt"
"strings"
)
func main() {
const data = `{"id":9007199254740993}`
// Default: float64 → precision loss.
var loose map[string]any
_ = json.Unmarshal([]byte(data), &loose)
fmt.Printf("loose: %v\n", loose["id"])
// With UseNumber: preserve digits.
dec := json.NewDecoder(strings.NewReader(data))
dec.UseNumber()
var precise map[string]json.Number
_ = dec.Decode(&precise)
id := precise["id"]
asInt, err := id.Int64()
fmt.Printf("precise string: %s, Int64: %d, err: %v\n", id, asInt, err)
}
Task 10: Map a scanner.go state transition¶
Goal: Read encoding/json/scanner.go and explain one state transition (e.g., stateBeginValue → stateInString). Difficulty: Middle Skills: Source reading.
Steps¶
- Open
$GOROOT/src/encoding/json/scanner.go. - Find
stateBeginValue. - Trace the case
'"'→ it setss.step = stateInStringand returnsscanBeginLiteral. - Write a 3-sentence summary of the state machine for strings.
Acceptance criteria¶
- You can name three states and one transition each.
Hint
Each state is a function with signature `func(*scanner, byte) int`.Reference solution
The scanner is a byte-at-a-time state machine. Each state is a function pointer (`s.step`). - `stateBeginValue('"')`: sees `"`, sets `s.step = stateInString`, returns `scanBeginLiteral`. - `stateInString('\\')`: sees a backslash, sets `s.step = stateInStringEsc`, returns `scanContinue`. - `stateInString('"')`: sees the closing quote, sets `s.step = stateEndValue`, returns `scanContinue`. Three states, three transitions; the same pattern (`s.step = ...`) chains them. The scanner doesn't allocate per byte; it returns small integer "actions" that the parent caller (`Decoder.refill`/`readValue`) interprets.Task 11: Benchmark stdlib vs codegen alternative¶
Goal: Measure the cost difference between reflect-based and codegen-based JSON. Difficulty: Senior Skills: Benchmarking, go test -bench.
Steps¶
- Define a moderately complex struct.
- Write a benchmark that calls
json.Marshalin a loop. - (Optional) Generate an
easyjsonorsonic-based marshaler for the same type. Compare. - Report ns/op, B/op, allocs/op.
Acceptance criteria¶
- Benchmarks run via
go test -bench=. -benchmem. - You can identify the bottleneck (reflect calls, allocations).
Hint
Even without a codegen library, benchmark `json.Marshal` against a hand-written `MarshalJSON` and you'll see the gap.Reference solution
// bench_test.go
package bench
import (
"encoding/json"
"strconv"
"testing"
)
type Event struct {
ID int64 `json:"id"`
Type string `json:"type"`
User string `json:"user"`
Value int `json:"value"`
}
var e = Event{ID: 12345, Type: "login", User: "aybek", Value: 42}
func BenchmarkStdlibMarshal(b *testing.B) {
for i := 0; i < b.N; i++ {
_, _ = json.Marshal(e)
}
}
// Hand-rolled — what a codegen library would produce.
func (e Event) MarshalManual() []byte {
buf := make([]byte, 0, 96)
buf = append(buf, `{"id":`...)
buf = strconv.AppendInt(buf, e.ID, 10)
buf = append(buf, `,"type":"`...)
buf = append(buf, e.Type...)
buf = append(buf, `","user":"`...)
buf = append(buf, e.User...)
buf = append(buf, `","value":`...)
buf = strconv.AppendInt(buf, int64(e.Value), 10)
buf = append(buf, '}')
return buf
}
func BenchmarkManualMarshal(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = e.MarshalManual()
}
}
Task 12: Pre-warm the typeEncoder cache¶
Goal: Avoid the first-call latency for Marshal of a complex type by pre-warming the reflect cache at startup. Difficulty: Senior Skills: init, cold-start optimization.
Steps¶
- In
init(), marshal one zero value of each type your service handles. - Measure first-call vs second-call latency before and after.
Acceptance criteria¶
- After pre-warming, first request to a handler has the same latency as steady state.
Hint
The cache key is `reflect.Type`. One marshal per type is sufficient.Reference solution
package svc
import "encoding/json"
type Request struct {
UserID int `json:"user_id"`
Action string `json:"action"`
}
type Response struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
func init() {
// Warm typeEncoder cache for known wire types.
_, _ = json.Marshal(Request{})
_, _ = json.Marshal(Response{})
}
Task 13: DoS guard with MaxBytesReader¶
Goal: Reject oversized JSON payloads before allocation explodes. Difficulty: Senior Skills: http.MaxBytesReader, Decoder.
Steps¶
- Wrap
r.Bodywithhttp.MaxBytesReader(w, r.Body, 1<<20)(1 MiB). - Decode via
Decoder.Decode. - If
Decodereturns an error, distinguish "request too large" vs "malformed JSON".
Acceptance criteria¶
- Payloads over 1 MiB are rejected with HTTP 413.
- Smaller malformed payloads return 400.
Hint
`MaxBytesReader` returns `*http.MaxBytesError` on overrun (Go 1.19+).Reference solution
package main
import (
"encoding/json"
"errors"
"fmt"
"net/http"
)
type Req struct{ Msg string }
func handler(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var q Req
if err := json.NewDecoder(r.Body).Decode(&q); err != nil {
var maxBytes *http.MaxBytesError
if errors.As(err, &maxBytes) {
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
return
}
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
fmt.Fprintf(w, "got %q\n", q.Msg)
}
func main() {
http.HandleFunc("/", handler)
_ = http.ListenAndServe(":8080", nil)
}
Task 14: Stream a 100MB JSON file with minimal memory¶
Goal: Process a 100MB JSON file (containing a top-level array of objects) without allocating ~100MB. Difficulty: Senior Skills: Decoder with io.Reader, streaming pattern.
Steps¶
- Generate a 100MB test file containing many JSON objects in an array.
- Open with
os.Open; pass tojson.NewDecoder. - Process each element via the
Decode-in-looppattern from Task 6. - Measure RSS via
runtime.MemStats.
Acceptance criteria¶
- RSS stays well below file size (< 10MB target).
- All elements are processed.
Hint
Don't read the file into memory first. The `Decoder` reads as needed.Reference solution
package main
import (
"encoding/json"
"fmt"
"os"
"runtime"
)
type Item struct {
ID int `json:"id"`
}
func main() {
f, err := os.Open("big.json")
if err != nil {
panic(err)
}
defer f.Close()
dec := json.NewDecoder(f)
tok, _ := dec.Token()
if d, ok := tok.(json.Delim); !ok || d != '[' {
panic("expected [")
}
count := 0
for dec.More() {
var it Item
if err := dec.Decode(&it); err != nil {
panic(err)
}
count++
}
_, _ = dec.Token()
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("decoded %d items; HeapAlloc=%dKB\n", count, m.HeapAlloc/1024)
}
Task 15: Custom marshaler for a fixed date format¶
Goal: Emit time.Time as "2006-01-02" (date only) rather than RFC3339. Difficulty: Senior Skills: Wrapper type, Marshaler/Unmarshaler.
Steps¶
- Define
type Date time.Time(or a struct wrappingtime.Time). - Implement
MarshalJSON/UnmarshalJSONusingtime.Format("2006-01-02")andtime.Parse. - Round-trip a value.
Acceptance criteria¶
- Output is
"2026-05-29"not"2026-05-29T00:00:00Z". - Round-trip preserves the date.
Hint
Apply the method to the wrapper type, not `time.Time` directly (you can't add methods to non-local types).Reference solution
package main
import (
"encoding/json"
"fmt"
"strconv"
"time"
)
type Date time.Time
const dateLayout = "2006-01-02"
func (d Date) MarshalJSON() ([]byte, error) {
return []byte(strconv.Quote(time.Time(d).Format(dateLayout))), nil
}
func (d *Date) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
t, err := time.Parse(dateLayout, s)
if err != nil {
return err
}
*d = Date(t)
return nil
}
type Booking struct {
When Date `json:"when"`
}
func main() {
b := Booking{When: Date(time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC))}
out, _ := json.Marshal(b)
fmt.Println(string(out))
var back Booking
_ = json.Unmarshal(out, &back)
fmt.Println(time.Time(back.When))
}
Task 16: Diff two JSON documents¶
Goal: Decode two JSON documents into any and recursively report differences. Difficulty: Senior Skills: any, type switches, recursion.
Steps¶
- Decode each document via
json.Unmarshal(data, &v)wherevisany. - Walk both recursively; report differences at each path.
Acceptance criteria¶
- Differences in scalar values, missing keys, and array elements are reported with their JSON path.
Hint
Recall: `any` decoding produces `map[string]any` for objects, `[]any` for arrays, `float64` for numbers, `string` for strings, `bool`, `nil`.Reference solution
package main
import (
"encoding/json"
"fmt"
"reflect"
)
func diff(path string, a, b any) {
if reflect.DeepEqual(a, b) {
return
}
switch av := a.(type) {
case map[string]any:
bv, ok := b.(map[string]any)
if !ok {
fmt.Printf("%s: type changed (%T -> %T)\n", path, a, b)
return
}
for k, av2 := range av {
diff(path+"."+k, av2, bv[k])
}
for k := range bv {
if _, in := av[k]; !in {
fmt.Printf("%s.%s: added (%v)\n", path, k, bv[k])
}
}
case []any:
bv, ok := b.([]any)
if !ok {
fmt.Printf("%s: type changed (%T -> %T)\n", path, a, b)
return
}
n := len(av)
if len(bv) > n {
n = len(bv)
}
for i := 0; i < n; i++ {
var ai, bi any
if i < len(av) {
ai = av[i]
}
if i < len(bv) {
bi = bv[i]
}
diff(fmt.Sprintf("%s[%d]", path, i), ai, bi)
}
default:
fmt.Printf("%s: %v -> %v\n", path, a, b)
}
}
func main() {
a := []byte(`{"name":"a","tags":["x","y"],"n":1}`)
b := []byte(`{"name":"a","tags":["x","z"],"n":2,"new":true}`)
var av, bv any
_ = json.Unmarshal(a, &av)
_ = json.Unmarshal(b, &bv)
diff("$", av, bv)
}
Task 17: pprof a Marshal-heavy service¶
Goal: Identify the JSON hotspot in a service via CPU profile. Difficulty: Senior Skills: net/http/pprof, go tool pprof.
Steps¶
- Build an HTTP service that marshals a complex struct on every request.
- Import
_ "net/http/pprof"and expose:6060. - Drive load with
heyorwrkfor 30s. - Capture
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=10. - Find
encoding/json.(*encodeState).reflectValue(or similar) in the top.
Acceptance criteria¶
- The profile shows JSON encoding among the top frames.
- You can identify where pre-warming (Task 12) or codegen (Task 11) would help.
Hint
Reflect-heavy frames show as `encoding/json.typeEncoder`, `structEncoder.encode`, `reflect.Value.Field`.Reference solution
package main
import (
"encoding/json"
"net/http"
_ "net/http/pprof"
)
type Bundle struct {
ID int `json:"id"`
Items []map[string]any `json:"items"`
Meta map[string]interface{} `json:"meta"`
}
var b = Bundle{
ID: 1,
Items: []map[string]any{
{"sku": "A", "qty": 1, "price": 9.99},
{"sku": "B", "qty": 2, "price": 4.50},
},
Meta: map[string]interface{}{"region": "eu", "v": 3},
}
func handler(w http.ResponseWriter, r *http.Request) {
out, _ := json.Marshal(b)
w.Write(out)
}
func main() {
go func() { _ = http.ListenAndServe(":6060", nil) }()
http.HandleFunc("/", handler)
_ = http.ListenAndServe(":8080", nil)
}
Task 18: Compare encoding/json vs encoding/json/v2¶
Goal: Write a 1-page comparison of v1 vs v2 (proposal 71497, available as experimental in Go 1.25+). Difficulty: Staff Skills: Proposal reading, source comparison.
Steps¶
- Read the v2 proposal at https://github.com/golang/go/issues/71497.
- Skim the experimental package at https://github.com/go-json-experiment/json.
- Note: options-as-args (vs Encoder methods), explicit zero handling, faster decoder, deterministic ordering, type-strict scanning.
- Write a markdown table: v1 behavior / v2 behavior / migration impact.
Acceptance criteria¶
- A markdown table with at least 8 rows covering API shape, options, performance, correctness, omitempty, durations, unknown fields, scanning.
Hint
The single biggest semantic change: v2 has `omitzero` (truly zero) separate from `omitempty` (a slightly different "empty" definition).Reference solution
| Aspect | v1 (`encoding/json`) | v2 (`encoding/json/v2`) | Migration | |---|---|---|---| | API shape | `Marshal(v)`, `Decoder.X()` methods | `Marshal(v, opts...)` functional options | Mostly mechanical; opts replace setters | | `omitempty` | "empty" = zero value, but inconsistent (zero `time.Time` not empty) | `omitzero` (true Go zero); `omitempty` redefined for slices/maps only | Audit tags; some fields newly omitted | | Unknown fields | Default: ignore | Default: still ignore; `RejectUnknownMembers` cleaner than v1 `DisallowUnknownFields` | Cleaner API | | Performance | Reflect-cached; ~2-4x slower than codegen | Substantially faster decoder; cache more efficient | Free speedup on upgrade | | Number handling | float64 by default; `json.Number` opt-in | Same defaults; explicit options for arbitrary precision | Same defaults | | Map ordering on Marshal | Lexicographic | Lexicographic by default, configurable | Same default | | `MarshalJSON`/`UnmarshalJSON` | Same | Same; new MarshalerTo/UnmarshalerFrom for streaming | Optional; faster for big payloads | | HTML escape | On by default; `SetEscapeHTML(false)` opt-out | Off by default; opt-in | Audit any places relying on HTML escape | | Time | RFC3339 always | Same default; opts for layout/duration handling | Less ceremony for date-only | | Errors | Generic `*json.SyntaxError` | Structured, with byte offsets and path | Easier to surface to clients | Notes: - v2 lives in `encoding/json/v2` (proposed for stdlib) and `github.com/go-json-experiment/json` (experimental package). - The intent is that v1 keeps working unchanged; v2 is opt-in. - Migration is gradual: v2's `MarshalEncode`/`UnmarshalDecode` can interop with v1 types via `Marshaler`/`Unmarshaler` methods.Task 19: JSON-Lines (NDJSON) encoder/decoder pair¶
Goal: Build a streaming encoder/decoder for newline-delimited JSON (one object per line). Common for logs and Kafka payloads. Difficulty: Staff Skills: Encoder, Decoder, line semantics.
Steps¶
- Encoder side: use
json.NewEncoder(w)— it already writes a trailing newline after eachEncode. That's already NDJSON. - Decoder side: use
json.NewDecoder(r)— it tolerates whitespace between values. That's already NDJSON. - Wrap each side to add convenience (channels, error handling, batch flush).
Acceptance criteria¶
- Encoder writes one valid JSON object per line.
- Decoder yields one value per Decode until EOF.
- Works through a pipe (
io.Pipe) without buffering whole stream.
Hint
`Encoder.Encode` already appends `\n`. No extra work.Reference solution
package main
import (
"encoding/json"
"fmt"
"io"
"strings"
)
type Event struct {
ID int `json:"id"`
Msg string `json:"msg"`
}
func encodeAll(w io.Writer, events []Event) error {
enc := json.NewEncoder(w)
for _, e := range events {
if err := enc.Encode(e); err != nil {
return err
}
}
return nil
}
func decodeAll(r io.Reader) ([]Event, error) {
dec := json.NewDecoder(r)
var out []Event
for {
var e Event
if err := dec.Decode(&e); err != nil {
if err == io.EOF {
return out, nil
}
return nil, err
}
out = append(out, e)
}
}
func main() {
var buf strings.Builder
_ = encodeAll(&buf, []Event{
{ID: 1, Msg: "hello"},
{ID: 2, Msg: "world"},
})
fmt.Print(buf.String())
events, _ := decodeAll(strings.NewReader(buf.String()))
fmt.Printf("decoded %d events\n", len(events))
}
Task 20: Schema-evolution decoder with version field¶
Goal: Decode payloads where the schema has evolved across versions. Older clients still send v1; newer clients send v2 with extra fields and renamed members. Difficulty: Staff Skills: Two-phase decode, polymorphism, version dispatch.
Steps¶
- Define a wire format with a
"version"field at the top. - First-phase decode peeks at the version.
- Second-phase decodes into the version-specific struct.
- Convert each version into a canonical internal type.
Acceptance criteria¶
- v1 and v2 payloads both decode into the same internal
Usertype. - Forward compatibility: a v3 payload is rejected cleanly with a version error.
Hint
Use `json.RawMessage` for the body so you parse it twice efficiently: first the discriminator, then the payload.Reference solution
package main
import (
"encoding/json"
"fmt"
)
// Internal canonical type.
type User struct {
ID int
Email string
}
// Wire formats per version.
type wireV1 struct {
UID int `json:"uid"`
Mail string `json:"mail"`
}
type wireV2 struct {
ID int `json:"id"`
Email string `json:"email"`
}
type envelope struct {
Version int `json:"version"`
Data json.RawMessage `json:"data"`
}
func parseUser(data []byte) (User, error) {
var env envelope
if err := json.Unmarshal(data, &env); err != nil {
return User{}, err
}
switch env.Version {
case 1:
var w wireV1
if err := json.Unmarshal(env.Data, &w); err != nil {
return User{}, err
}
return User{ID: w.UID, Email: w.Mail}, nil
case 2:
var w wireV2
if err := json.Unmarshal(env.Data, &w); err != nil {
return User{}, err
}
return User{ID: w.ID, Email: w.Email}, nil
default:
return User{}, fmt.Errorf("unsupported version %d", env.Version)
}
}
func main() {
v1 := []byte(`{"version":1,"data":{"uid":7,"mail":"a@b.com"}}`)
v2 := []byte(`{"version":2,"data":{"id":8,"email":"c@d.com"}}`)
v3 := []byte(`{"version":3,"data":{}}`)
for _, b := range [][]byte{v1, v2, v3} {
u, err := parseUser(b)
fmt.Printf("user=%+v err=%v\n", u, err)
}
}
Self-grading rubric¶
| Level | Tasks done | What it means |
|---|---|---|
| Junior | 1-4 | You can use encoding/json for basic encode/decode and know about tags. |
| Middle | 5-10 | You can implement custom marshalers, stream large data, and read scanner source. |
| Senior | 11-17 | You can profile, optimize, and harden JSON for production. |
| Staff | 18-20 | You can reason about API design (v1/v2), build streaming wire protocols, and design schema-evolution strategies. |
Aim for 80% completion at your target level before moving up.
Stretch challenges¶
S1: Build a Decoder middleware that records the JSON path of every decode error. When a request fails to decode, return a detailed {"error":"field 'user.address.zip' expected string, got number"} instead of the default generic message. Hint: wrap Decoder.Decode and intercept *json.UnmarshalTypeError.
S2: Implement a streaming JSON-to-CSV converter. Read a JSON-Lines file via Decoder, identify the schema from the first record, emit CSV rows. Bonus: handle nested objects via dot notation. Memory must not scale with input length.
S3: Build a JSON-schema validator on top of json.RawMessage. Given a schema (subset of JSON Schema: required fields, types, min/max), validate payloads without unmarshaling into typed structs. Compare to existing libraries (xeipuuv/gojsonschema) for completeness and speed.