← Back to Blog

Go JSON Encoding: struct tags, Marshaling, and Custom Encoders

March 30, 2026 2 min read By CodeTidy Team

The JSON Encoding Conundrum: Taming Go's json.Marshal

We've all been there - staring at a seemingly simple JSON encoding task in Go, only to be tripped up by the nuances of json.Marshal. The encoding/json package is powerful, but its defaults can lead to unexpected results. In this article, we'll dive into the world of Go JSON encoding, exploring struct tags, custom encoders, and the trade-offs between encoding/json and alternative libraries like sonic.

Table of Contents

  • Understanding json.Marshal and Struct Tags
  • Customizing JSON Output with MarshalJSON
  • The Power of omitempty and string Struct Tags
  • Optimizing Performance with encoding/json vs sonic
  • Putting it all Together: Real-World Examples
  • Key Takeaways
  • FAQ

Understanding json.Marshal and Struct Tags

When working with Go structs, json.Marshal is often the go-to function for encoding data into JSON. However, its default behavior can lead to unexpected results. Let's consider a simple example:

type Person struct {
    Name  string
    Email string
}

person := Person{Name: "John Doe", Email: "john@example.com"}
jsonBytes, err := json.Marshal(person)
if err != nil {
    log.Fatal(err)
}
fmt.Println(string(jsonBytes))
// Output: {"Name":"John Doe","Email":"john@example.com"}

As you can see, the resulting JSON uses the struct field names as keys. But what if we want to customize the output? That's where struct tags come in. We can use the json struct tag to specify a custom key name:

type Person struct {
    Name  string `json:"full_name"`
    Email string `json:"email_address"`
}

person := Person{Name: "John Doe", Email: "john@example.com"}
jsonBytes, err := json.Marshal(person)
if err != nil {
    log.Fatal(err)
}
fmt.Println(string(jsonBytes))
// Output: {"full_name":"John Doe","email_address":"john@example.com"}

Customizing JSON Output with MarshalJSON

Sometimes, we need more control over the JSON output than struct tags can provide. That's where the MarshalJSON method comes in. By implementing this method on our struct, we can customize the encoding process:

type Person struct {
    Name  string
    Email string
}

func (p Person) MarshalJSON() ([]byte, error) {
    return json.Marshal(map[string]string{
        "full_name": p.Name,
        "email_address": p.Email,
    })
}

person := Person{Name: "John Doe", Email: "john@example.com"}
jsonBytes, err := json.Marshal(person)
if err != nil {
    log.Fatal(err)
}
fmt.Println(string(jsonBytes))
// Output: {"full_name":"John Doe","email_address":"john@example.com"}

The Power of omitempty and string Struct Tags

Two particularly useful struct tags are omitempty and string. omitempty allows us to exclude fields from the JSON output if they are empty or zero-valued:

type Person struct {
    Name  string `json:"full_name"`
    Email string `json:"email_address,omitempty"`
}

person := Person{Name: "John Doe"}
jsonBytes, err := json.Marshal(person)
if err != nil {
    log.Fatal(err)
}
fmt.Println(string(jsonBytes))
// Output: {"full_name":"John Doe"}

The string tag, on the other hand, allows us to specify that a field should be encoded as a string, even if it's not a string type:

type Person struct {
    ID   int `json:"id,string"`
    Name string
}

person := Person{ID: 123, Name: "John Doe"}
jsonBytes, err := json.Marshal(person)
if err != nil {
    log.Fatal(err)
}
fmt.Println(string(jsonBytes))
// Output: {"id":"123","Name":"John Doe"}

Optimizing Performance with encoding/json vs sonic

When it comes to JSON encoding performance, the choice between encoding/json and sonic can be significant. Sonic is a high-performance JSON encoding library that can outperform encoding/json in many cases. However, it's essential to consider the trade-offs:

  • encoding/json is part of the Go standard library, making it a convenient choice.
  • sonic requires an additional dependency, but offers better performance and more features.

Ultimately, the choice between encoding/json and sonic depends on your specific use case and performance requirements.

Putting it all Together: Real-World Examples

Let's take a look at a real-world example that puts it all together:

type User struct {
    ID        int    `json:"id"`
    Name      string `json:"name"`
    Email     string `json:"email"`
    CreatedAt time.Time `json:"created_at"`
}

func (u User) MarshalJSON() ([]byte, error) {
    type alias User
    return json.Marshal(&struct {
        alias
        CreatedAt string `json:"created_at"`
    }{
        alias:      (alias)(u),
        CreatedAt:  u.CreatedAt.Format(time.RFC3339),
    })
}

user := User{
    ID:        123,
    Name:      "John Doe",
    Email:     "john@example.com",
    CreatedAt: time.Now(),
}
jsonBytes, err := json.Marshal(user)
if err != nil {
    log.Fatal(err)
}
fmt.Println(string(jsonBytes))
// Output: {"id":123,"name":"John Doe","email":"john@example.com","created_at":"2023-03-09T14:30:00Z"}

Key Takeaways

  • Use struct tags to customize JSON key names and behavior.
  • Implement the MarshalJSON method for custom encoding logic.
  • Leverage omitempty and string struct tags for more control over JSON output.
  • Consider using sonic for high-performance JSON encoding.
  • Always test and benchmark your JSON encoding logic.

FAQ

Q: What's the difference between encoding/json and sonic?

A: encoding/json is part of the Go standard library, while sonic is a high-performance JSON encoding library that requires an additional dependency.

Q: How do I exclude empty fields from JSON output?

A: Use the omitempty struct tag on the field.

Q: Can I customize the JSON encoding process?

A: Yes, implement the MarshalJSON method on your struct.

AI agent tools available. The CodeTidy MCP Server gives Claude, Cursor, and other AI agents access to 60+ developer tools. One command: npx @codetidy/mcp