json.Unmarshal vs NewDecoder.Decode: which is faster in Go?

Online Python Trainer for Beginners

Learn Python easily without overwhelming theory. Solve practical tasks with automatic checking, get hints in Russian, and write code directly in your browser — no installation required.

Start Course

Two Acrobatic Brothers: Unmarshal and Decode



If you write in Go and work with JSON, sooner or later you face a choice: use json.Unmarshal or json.NewDecoder.Decode? This question is so popular that on Stack Overflow it has gathered thousands of views and dozens of votes. But the answer you find there often boils down to "it depends on the task."



Today we won't just give an abstract answer. We'll conduct our own investigation: break down the inner workings of both methods, write benchmarks, show real code examples, and derive clear rules for when to use which. Buckle up — it's going to be intense!



The Gist: What are Unmarshal and Decode?



Both tools do the same thing — turn JSON into a Go struct. But they do it differently.



  • json.Unmarshal — takes a []byte and a pointer to a struct. All at once: bytes are ready, struct is ready, let's go. It works "head-on" — deserializes the entire data array in memory.
  • json.NewDecoder.Decode — works with a stream (io.Reader). You create a decoder, pass it a reader (e.g., os.File or http.Response.Body), and call Decode. The decoder reads data in chunks, without loading everything into memory at once.


Seemingly, the difference is obvious: for large data — Decoder, for small data — Unmarshal. But the devil, as always, is in the details.



Under the Hood: How They Work Internally



json.Unmarshal — A Sniper Shot



Unmarshal is a function that:

  1. Takes a slice of bytes.
  2. Allocates memory for the resulting struct.
  3. Parses the entire JSON in one pass and fills the struct.


Simple and elegant. But there's a catch: if you pass a large JSON (e.g., 100 MB) to Unmarshal, you first have to read those 100 MB into memory. Then Unmarshal allocates more memory for the struct. Result — double RAM consumption.



json.NewDecoder.Decode — A Plumber



Decoder works differently:

  1. A new decoder is created, wrapping an io.Reader.
  2. The decoder reads data in buffers (default 4 KB).
  3. As it reads, it parses JSON token by token and fills the struct.


The advantage — you don't store the entire JSON in memory. The disadvantage — slightly more overhead for buffer management and streaming reads.



Experiment: Benchmarks Don't Lie



Let's test the theory in practice. We'll write a simple benchmark for three scenarios:

  • Small JSON (1 KB)
  • Medium JSON (100 KB)
  • Large JSON (10 MB)


package main

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

type Data struct { ID int `json:"id"` Name string `json:"name"` Items []int `json:"items"`}

func generateJSON(size int) []byte { data := Data{ ID: 1, Name: "test", Items: make([]int, size), } for i := 0; i < size; i++ { data.Items[i] = i } b, _ := json.Marshal(data) return b}

func BenchmarkUnmarshal(b *testing.B) { jsonData := generateJSON(1000) // 1 KB var result Data for i := 0; i < b.N; i++ { json.Unmarshal(jsonData, &result) }}

func BenchmarkDecoder(b *testing.B) { jsonData := generateJSON(1000) // 1 KB var result Data for i := 0; i < b.N; i++ { reader := bytes.NewReader(jsonData) json.NewDecoder(reader).Decode(&result) }}


Results (on my laptop, Go 1.22):



Size

Blogs

Book Recommendations