Changing the JSON package in an existing Go application should not be based only on API compatibility. Performance should also be measured before deciding whether the change is worthwhile.
JSON processing can become a noticeable part of application cost when a service handles large request bodies, high request volumes, nested objects, event streams, or repeated serialization and deserialization.
The right way to evaluate encoding/json/v2 is to benchmark the workload that the application actually processes.
A small benchmark using a three-field struct may show a difference, but it may not represent the behavior of a production API that processes hundreds of fields, nested arrays, optional properties, and custom data types.
This article explains how to create useful JSON benchmarks in Go, compare serialization and deserialization workloads, measure memory usage, and interpret the results before changing an existing application.
Why JSON Performance Should Be Measured Before Migration
A JSON package is only one part of an application's request-processing pipeline.
For an HTTP API, the complete operation may look like this:
HTTP Request
|
v
Request Body
|
v
JSON Decode
|
v
Validation
|
v
Business Logic
|
v
Database
|
v
JSON Encode
|
v
HTTP ResponseEven if JSON decoding becomes faster, the overall request may not become noticeably faster if database access or another operation dominates the response time.
The opposite is also possible.
If an application spends a significant amount of CPU time encoding and decoding JSON, improvements in the JSON layer can matter more.
Therefore, benchmark the complete workload before drawing conclusions.
What Should Be Measured?
At minimum, compare these areas:
- JSON marshaling.
- JSON unmarshaling.
- Allocation count.
- Allocated memory.
- Large payload processing.
- Small payload processing.
- Nested object processing.
- Array processing.
- Custom JSON types.
- Streaming workloads when applicable.
For HTTP services, it can also be useful to measure the complete request path separately.
Create a Representative Data Model
Start with a model that resembles the application's actual data.
For example:
type User struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Active bool `json:"active"`
Roles []string `json:"roles"`
Address Address `json:"address"`
CreatedAt string `json:"created_at"`
}
type Address struct {
Street string `json:"street"`
City string `json:"city"`
Country string `json:"country"`
ZipCode string `json:"zip_code"`
}Create representative test data:
var testUser = User{
ID: 1001,
Name: "Rahul Kumar",
Email: "[email protected]",
Active: true,
Roles: []string{"developer", "admin"},
Address: Address{
Street: "Main Street",
City: "Patna",
Country: "India",
ZipCode: "800001",
},
CreatedAt: "2026-11-05T10:30:00Z",
}The goal is not to create a perfect copy of production data.
The goal is to create benchmark data with similar structure, size, nesting, and value characteristics.
Benchmark JSON Marshaling
A Go benchmark uses the testing package.
For the v2 implementation:
func BenchmarkJSONV2Marshal(b *testing.B) {
for i := 0; i < b.N; i++ {
_, err := json.Marshal(testUser)
if err != nil {
b.Fatal(err)
}
}
}A corresponding benchmark can be created for the existing implementation:
func BenchmarkJSONV1Marshal(b *testing.B) {
for i := 0; i < b.N; i++ {
_, err := json.Marshal(testUser)
if err != nil {
b.Fatal(err)
}
}
}Because both packages expose similar basic APIs, the benchmark structure can remain simple.
However, the two benchmarks must use the same input data.
Otherwise, the comparison is not meaningful.
Benchmark JSON Unmarshaling
Serialization is only half of the workload.
For API servers and message consumers, deserialization can be just as important.
First create a JSON payload:
var testJSON []byte
func init() {
var err error
testJSON, err = json.Marshal(testUser)
if err != nil {
panic(err)
}
}Then benchmark decoding:
func BenchmarkJSONV2Unmarshal(b *testing.B) {
for i := 0; i < b.N; i++ {
var user User
if err := json.Unmarshal(testJSON, &user); err != nil {
b.Fatal(err)
}
}
}The v1 benchmark should use the same testJSON:
func BenchmarkJSONV1Unmarshal(b *testing.B) {
for i := 0; i < b.N; i++ {
var user User
if err := json.Unmarshal(testJSON, &user); err != nil {
b.Fatal(err)
}
}
}Using identical input ensures that the comparison focuses on the JSON implementation rather than differences in the data.
Measure Allocations and Memory
Execution time alone is not enough.
A benchmark can also report allocation information.
Run benchmarks with memory statistics enabled:
go test -bench=. -benchmemThe benchmark output includes information such as:
ns/op
B/op
allocs/opThese values provide three useful perspectives:
- ns/op indicates approximate time per operation.
- B/op indicates bytes allocated per operation.
- allocs/op indicates the number of allocations per operation.
Lower values are generally better, but the most important metric depends on the application.
For a high-throughput API, allocation pressure can be important because excessive allocations can increase garbage-collection work.
Use the Same Benchmark Environment
Benchmark comparisons are only useful when the environment is reasonably consistent.
Avoid comparing results from:
- Different machines.
- Different Go versions.
- Different compiler settings.
- Different operating-system environments.
- Different benchmark data.
- Different background workloads.
Run both implementations under the same conditions.
A practical benchmark process is:
go test -bench=JSON -benchmemThen repeat the benchmark several times if the results vary significantly.
For more consistent comparison, Go's benchmark tooling can also be combined with repeated benchmark runs and statistical comparison tools.
Benchmark Small and Large JSON Payloads
One benchmark is rarely enough.
An API may process both small and large payloads.
For example, create a small payload:
var smallUser = User{
ID: 1,
Name: "Rahul",
Email: "[email protected]",
Active: true,
}Then create a larger workload:
type UserList struct {
Users []User `json:"users"`
}Generate multiple records:
func createUsers(count int) UserList {
users := make([]User, 0, count)
for i := 0; i < count; i++ {
users = append(users, User{
ID: int64(i + 1),
Name: "Developer",
Email: "[email protected]",
Active: true,
Roles: []string{"developer"},
Address: Address{
Street: "Main Street",
City: "Patna",
Country: "India",
ZipCode: "800001",
},
CreatedAt: "2026-11-05T10:30:00Z",
})
}
return UserList{
Users: users,
}
}This lets you compare behavior as the JSON document grows.
Large payloads can expose allocation or processing differences that are not visible with small objects.
Join the conversation! Your thoughts help the community grow.