iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article

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.

  1. Custom UnmarshalJSON - Write smart logic to handle dynamic structures (can adapt to unexpected dynamic behavior without code changes).
  2. 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.Unmarshal calls + string operations + map lookup
  • Standard: Just a single json.Unmarshal call

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

  1. Benchmark everything - Numbers are everything
  2. Trust the standard library - In principle, it's faster than custom implementations
  3. Simple code is fast code - Complexity comes with a performance cost
  4. 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