iTranslated by AI
Custom UnmarshalJSON vs. Standard: A Go Performance Showdown
A deep dive into JSON unmarshaling performance with actual benchmark data
In Go, when dealing with complex and dynamic JSON structures, you might feel tempted to write custom JSON Unmarshal logic.
So this time, I compared the performance using benchmarks to see if writing a custom Unmarshal is worth it!
If you usually write json.Unmarshal without giving it much thought, this should be a good learning experience!
The Challenge
I needed to receive a JSON API that returns a dynamically nested structure and process the response data.
{
"id": "test-123",
"objectType": "UNIT",
"content": {
"unit": {
"url": "https://example.com/movie",
"checksum": "abc123"
}
}
}
The content field contains different sub-objects (unit, category, item) depending on the objectType.
I considered two approaches for this challenge.
- Custom UnmarshalJSON - Write smart logic to handle dynamic structures (can adapt to unexpected dynamic behavior without code changes).
- Standard JSON with Union Structs - Leave everything to Go's built-in JSON package (cannot handle unexpected behavior).
Implementation
Approach 1: Custom UnmarshalJSON
type Resource struct {
ID string `json:"id"`
ObjectType string `json:"objectType"`
Content Details // Single nested struct
}
type Details struct {
Url string `json:"url"`
Checksum string `json:"checksum"`
}
func (r *Resource) UnmarshalJSON(data []byte) error {
var raw struct {
ID string `json:"id"`
ObjectType string `json:"objectType"`
Content map[string]json.RawMessage `json:"content"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
r.ID = raw.ID
r.ObjectType = raw.ObjectType
// Dynamic field selection based on objectType
objectTypeKey := strings.ToLower(raw.ObjectType)
contentData, exists := raw.Content[objectTypeKey]
if !exists {
return fmt.Errorf("missing '%s' field in content", objectTypeKey)
}
return json.Unmarshal(contentData, &r.Content)
}
Approach 2: Standard JSON with Union Structs
type Item struct {
ID string `json:"id"`
ObjectType string `json:"objectType"`
Content ContentUnion `json:"content"`
}
type ContentUnion struct {
Item *ContentDetail `json:"item,omitempty"`
Category *ContentDetail `json:"category,omitempty"`
Unit *ContentDetail `json:"unit,omitempty"`
}
type ContentDetail struct {
Url string `json:"url"`
Checksum string `json:"checksum"`
}
// Helper method to get the active content
func (c *ContentUnion) GetActive() *ContentDetail {
if c.ObjectType == "unit" && c.Unit != nil {
return c.Unit
}
if c.ObjectType == "category" && c.Category != nil {
return c.Category
}
if c.ObjectType == "item" && c.Item != nil {
return c.Item
}
return nil
}
Benchmark Results
I ran comprehensive benchmarks for both approaches using Go's testing framework.
goos: darwin
goarch: arm64
pkg: zeus/pkg/contents/canalplus
cpu: Apple M3
BenchmarkResource_CustomUnmarshal-8 86,374 14,365 ns/op 1,376 B/op 27 allocs/op
BenchmarkItem_StandardUnmarshal-8 200,640 8,301 ns/op 512 B/op 14 allocs/op
Results
The standard JSON approach (Item) completely outperformed the custom implementation (Resource).
It makes sense when you think about it—the custom implementation simply has more processing to do, so it’s bound to be slower.
However, seeing a performance difference of more than double was a great learning experience.
- 🚀 1.73x faster execution time (8,301 ns vs 14,365 ns)
- 💾 2.69x less memory usage (512 B vs 1,376 B per operation)
- 🔄 1.93x fewer allocations (14 vs 27 per operation)
- 📈 2.32x higher throughput (200,640 vs 86,374 operations)
Why the Standard Approach Wins
1. Go's JSON Package is Highly Optimized
The encoding/json package has undergone low-level optimizations over many years, and custom code often cannot compete with it.
2. Fewer JSON Operations
-
Custom: Two
json.Unmarshalcalls + string operations + map lookup -
Standard: Just a single
json.Unmarshalcall
3. Reduced Memory Pressure
While the standard approach maps directly to struct fields, custom logic must create an intermediate map, leading to additional allocations.
4. CPU Cache Efficiency
With fewer operations, CPU cache utilization improves and context switches decrease.
Putting Go Philosophy into Practice
This benchmark perfectly demonstrates Go's philosophy: "Simple is best." The standard library exists for a reason, and it's not easy to outmatch it with a casual custom implementation.
When to Use Each Approach
When to use standard JSON:
- ✅ Performance is critical
- ✅ Data can be modeled with struct tags
- ✅ The JSON structure is somewhat predictable
- ✅ You prioritize maintainability
When to use custom UnmarshalJSON:
- ⚠️ The JSON structure is extremely dynamic
- ⚠️ Complex validation logic is required
- ⚠️ Standard JSON truly cannot handle the processing
- ⚠️ Flexibility is more important than performance
Key Takeaways for Go Developers
- Benchmark everything - Numbers are everything
- Trust the standard library - In principle, it's faster than custom implementations
- Simple code is fast code - Complexity comes with a performance cost
- Union structs are powerful - They can handle "dynamic" JSON scenarios to some extent when inputs are limited
Conclusion
You might feel tempted to write a fancy custom JSON Unmarshal logic, but it won't win on performance. The standard JSON approach using union structs is not only simpler and more maintainable but also significantly faster.
In the end, it comes back to the basics: use the standard library if you can.
Have you ever felt similar doubts and verified JSON performance in Go? Have you had similar experiences with benchmarks? Let me know in the comments!
Discussion