Avatar

JSON and Struct Tags in Go: What Every Dev Should Know

Introduction

Working with JSON in Go is a fundamental skill that every developer needs to master. Whether you're building REST APIs, consuming external services, or storing configuration data, understanding how Go's struct tags work with JSON marshaling and unmarshaling is crucial. This comprehensive guide will walk you through everything you need to know about JSON struct tags, from basic usage to advanced techniques that will make your Go code more robust and maintainable.

What Are Struct Tags?

Struct tags in Go are string literals that provide metadata about struct fields. They're written as raw string literals (using backticks) and follow the field declaration. The Go runtime and various packages use these tags to control how struct fields are processed during operations like JSON marshaling, database mapping, and validation.

Basic Struct Tag Syntax:

basic-tags.go
1type User struct { 2 ID int `json:"id"` 3 Name string `json:"name"` 4 Email string `json:"email"` 5 Password string `json:"-"` // Excluded from JSON 6 Age int `json:"age,omitempty"` // Omitted if zero value 7}

The tag format follows the pattern: \`key:"value"\`. For JSON, the key is \`json\` and the value specifies how the field should be handled during JSON operations.

Common JSON Tag Options

Go's JSON package supports several tag options that give you fine-grained control over JSON serialization and deserialization.

Field Naming and Exclusion:

json-options.go
1type Product struct { 2 ID int `json:"id"` // Custom field name 3 ProductName string `json:"product_name"` // Snake case naming 4 Price float64 `json:"price"` // Standard naming 5 InternalID string `json:"-"` // Excluded from JSON 6 Discount float64 `json:"discount,omitempty"` // Omit if zero value 7 Tags []string `json:"tags,omitempty"` // Omit if empty slice 8 Metadata map[string]interface{} `json:"metadata,omitempty"` // Omit if nil 9}

String Option for Numbers:

string-option.go
1type APIResponse struct { 2 UserID int64 `json:"user_id,string"` // Marshaled as string 3 Timestamp int64 `json:"timestamp,string"` // Useful for large numbers 4 Amount float64 `json:"amount,string"` // Precision preservation 5} 6 7// JSON output: 8// { 9// "user_id": "1234567890123456789", 10// "timestamp": "1642694400", 11// "amount": "99.99" 12// }

Nested Structs and Embedding

Go handles nested structs and embedded fields elegantly with JSON tags, allowing you to create complex data structures that map cleanly to JSON.

Nested Structs:

nested-structs.go
1type Address struct { 2 Street string `json:"street"` 3 City string `json:"city"` 4 Country string `json:"country"` 5 ZipCode string `json:"zip_code"` 6} 7 8type User struct { 9 ID int `json:"id"` 10 Name string `json:"name"` 11 Email string `json:"email"` 12 Address Address `json:"address"` // Nested struct 13 WorkAddress *Address `json:"work_address,omitempty"` // Pointer to struct 14} 15 16// JSON output: 17// { 18// "id": 1, 19// "name": "John Doe", 20// "email": "john@example.com", 21// "address": { 22// "street": "123 Main St", 23// "city": "New York", 24// "country": "USA", 25// "zip_code": "10001" 26// } 27// }

Embedded Structs:

embedded-structs.go
1type Timestamps struct { 2 CreatedAt time.Time `json:"created_at"` 3 UpdatedAt time.Time `json:"updated_at"` 4} 5 6type User struct { 7 ID int `json:"id"` 8 Name string `json:"name"` 9 Email string `json:"email"` 10 Timestamps // Embedded struct - fields promoted to parent 11} 12 13// Alternative: Embed with custom JSON key 14type Product struct { 15 ID int `json:"id"` 16 Name string `json:"name"` 17 Price float64 `json:"price"` 18 Timestamps `json:"timestamps"` // Embedded as nested object 19} 20 21// User JSON (fields promoted): 22// { 23// "id": 1, 24// "name": "John", 25// "email": "john@example.com", 26// "created_at": "2025-01-20T10:00:00Z", 27// "updated_at": "2025-01-20T10:00:00Z" 28// } 29 30// Product JSON (nested object): 31// { 32// "id": 1, 33// "name": "Widget", 34// "price": 29.99, 35// "timestamps": { 36// "created_at": "2025-01-20T10:00:00Z", 37// "updated_at": "2025-01-20T10:00:00Z" 38// } 39// }

Custom JSON Marshaling

Sometimes struct tags aren't enough, and you need complete control over JSON serialization. Go provides interfaces that allow you to implement custom marshaling logic.

Implementing json.Marshaler and json.Unmarshaler:

custom-marshaling.go
1type Status int 2 3const ( 4 StatusInactive Status = iota 5 StatusActive 6 StatusSuspended 7) 8 9// Custom marshaling for Status 10func (s Status) MarshalJSON() ([]byte, error) { 11 switch s { 12 case StatusInactive: 13 return []byte(`"inactive"`), nil 14 case StatusActive: 15 return []byte(`"active"`), nil 16 case StatusSuspended: 17 return []byte(`"suspended"`), nil 18 default: 19 return nil, fmt.Errorf("unknown status: %d", s) 20 } 21} 22 23// Custom unmarshaling for Status 24func (s *Status) UnmarshalJSON(data []byte) error { 25 var str string 26 if err := json.Unmarshal(data, &str); err != nil { 27 return err 28 } 29 30 switch str { 31 case "inactive": 32 *s = StatusInactive 33 case "active": 34 *s = StatusActive 35 case "suspended": 36 *s = StatusSuspended 37 default: 38 return fmt.Errorf("unknown status: %s", str) 39 } 40 return nil 41} 42 43type User struct { 44 ID int `json:"id"` 45 Name string `json:"name"` 46 Status Status `json:"status"` 47} 48 49// Usage 50user := User{ID: 1, Name: "John", Status: StatusActive} 51data, _ := json.Marshal(user) 52fmt.Println(string(data)) 53// Output: {"id":1,"name":"John","status":"active"}

Advanced Techniques

Dynamic JSON Field Names:

dynamic-fields.go
1// Using map for dynamic fields 2type DynamicData struct { 3 ID int `json:"id"` 4 Data map[string]interface{} `json:"data"` 5} 6 7// Using json.RawMessage for deferred parsing 8type FlexibleResponse struct { 9 Type string `json:"type"` 10 Payload json.RawMessage `json:"payload"` 11} 12 13func (f *FlexibleResponse) ParsePayload(v interface{}) error { 14 return json.Unmarshal(f.Payload, v) 15} 16 17// Usage 18response := FlexibleResponse{ 19 Type: "user", 20 Payload: json.RawMessage(`{"id":1,"name":"John"}`), 21} 22 23var user User 24if err := response.ParsePayload(&user); err != nil { 25 log.Fatal(err) 26}

Conditional JSON Fields:

conditional-fields.go
1type User struct { 2 ID int `json:"id"` 3 Name string `json:"name"` 4 Email string `json:"email"` 5 Password string `json:"password,omitempty"` 6 IsAdmin bool `json:"is_admin,omitempty"` 7} 8 9// Custom marshaling for different contexts 10func (u User) MarshalJSON() ([]byte, error) { 11 type Alias User // Prevent infinite recursion 12 13 // Create a copy without sensitive fields 14 safe := struct { 15 Alias 16 Password string `json:"password,omitempty"` 17 }{ 18 Alias: Alias(u), 19 // Password is omitted by not setting it 20 } 21 22 return json.Marshal(safe) 23} 24 25// Alternative: Use different structs for different contexts 26type UserPublic struct { 27 ID int `json:"id"` 28 Name string `json:"name"` 29 Email string `json:"email"` 30} 31 32type UserPrivate struct { 33 UserPublic 34 Password string `json:"password"` 35 IsAdmin bool `json:"is_admin"` 36} 37 38func (u User) ToPublic() UserPublic { 39 return UserPublic{ 40 ID: u.ID, 41 Name: u.Name, 42 Email: u.Email, 43 } 44}

Real-World Example: API Response Handling

Here's a comprehensive example showing how to handle a complex API response with nested data, optional fields, and custom formatting.

api-response.go
1package main 2 3import ( 4 "encoding/json" 5 "fmt" 6 "log" 7 "time" 8) 9 10// Custom time format for API 11type APITime struct { 12 time.Time 13} 14 15func (t APITime) MarshalJSON() ([]byte, error) { 16 return []byte(fmt.Sprintf(""%s"", t.Format("2006-01-02T15:04:05Z"))), nil 17} 18 19func (t *APITime) UnmarshalJSON(data []byte) error { 20 var timeStr string 21 if err := json.Unmarshal(data, &timeStr); err != nil { 22 return err 23 } 24 25 parsed, err := time.Parse("2006-01-02T15:04:05Z", timeStr) 26 if err != nil { 27 return err 28 } 29 30 t.Time = parsed 31 return nil 32} 33 34// API response structures 35type APIResponse struct { 36 Success bool `json:"success"` 37 Message string `json:"message,omitempty"` 38 Data interface{} `json:"data,omitempty"` 39 Error *APIError `json:"error,omitempty"` 40 Timestamp APITime `json:"timestamp"` 41} 42 43type APIError struct { 44 Code int `json:"code"` 45 Message string `json:"message"` 46 Details string `json:"details,omitempty"` 47} 48 49type User struct { 50 ID int `json:"id"` 51 Username string `json:"username"` 52 Email string `json:"email"` 53 FullName string `json:"full_name"` 54 Avatar *string `json:"avatar"` // Pointer for nullable field 55 Roles []string `json:"roles,omitempty"` 56 Settings UserSettings `json:"settings"` 57 CreatedAt APITime `json:"created_at"` 58 UpdatedAt APITime `json:"updated_at"` 59 LastLogin *APITime `json:"last_login,omitempty"` // Nullable timestamp 60} 61 62type UserSettings struct { 63 Theme string `json:"theme"` 64 Language string `json:"language"` 65 Notifications bool `json:"notifications"` 66 Privacy PrivacySettings `json:"privacy"` 67} 68 69type PrivacySettings struct { 70 ProfileVisible bool `json:"profile_visible"` 71 EmailVisible bool `json:"email_visible"` 72} 73 74func main() { 75 // Example API response JSON 76 jsonData := `{ 77 "success": true, 78 "data": { 79 "id": 123, 80 "username": "johndoe", 81 "email": "john@example.com", 82 "full_name": "John Doe", 83 "avatar": null, 84 "roles": ["user", "moderator"], 85 "settings": { 86 "theme": "dark", 87 "language": "en", 88 "notifications": true, 89 "privacy": { 90 "profile_visible": true, 91 "email_visible": false 92 } 93 }, 94 "created_at": "2025-01-15T10:30:00Z", 95 "updated_at": "2025-01-20T14:22:33Z" 96 }, 97 "timestamp": "2025-01-20T15:00:00Z" 98 }` 99 100 // Parse the response 101 var response APIResponse 102 if err := json.Unmarshal([]byte(jsonData), &response); err != nil { 103 log.Fatal(err) 104 } 105 106 // Extract user data 107 userData, _ := json.Marshal(response.Data) 108 var user User 109 if err := json.Unmarshal(userData, &user); err != nil { 110 log.Fatal(err) 111 } 112 113 fmt.Printf("User: %s (%s)\n", user.FullName, user.Email) 114 fmt.Printf("Created: %s\n", user.CreatedAt.Format("2006-01-02")) 115 fmt.Printf("Theme: %s\n", user.Settings.Theme) 116 fmt.Printf("Roles: %v\n", user.Roles) 117 118 // Marshal back to JSON 119 output, _ := json.MarshalIndent(user, "", " ") 120 fmt.Println("\nJSON Output:") 121 fmt.Println(string(output)) 122}

Best Practices and Common Pitfalls

✅ Best Practices:

  • Use omitempty for optional fields to keep JSON clean
  • Use pointers for nullable fields (*string, *int, etc.)
  • Implement custom marshaling for complex types (enums, custom time formats)
  • Use json:"-" to exclude sensitive fields like passwords
  • Consider using different structs for different API contexts (public vs private)
  • Use json.RawMessage for deferred parsing of dynamic content
  • Validate JSON input after unmarshaling

❌ Common Pitfalls:

  • Forgetting that unexported fields are ignored by JSON package
  • Not handling nil pointers when using omitempty
  • Using interface when a specific type would be better
  • Not validating required fields after unmarshaling
  • Infinite recursion in custom MarshalJSON methods
  • Not handling time zones properly in custom time types
  • Exposing internal struct fields in public APIs

💡 Performance Tips:

  • Use json.Decoder for streaming large JSON files
  • Pool JSON encoders/decoders for high-throughput applications
  • Consider using easyjson or ffjson for performance-critical code
  • Avoid unnecessary allocations in custom marshal methods
  • Use struct field ordering to optimize memory layout

Conclusion

Mastering JSON struct tags in Go is essential for building robust applications that interact with APIs, databases, and configuration files. The combination of struct tags, custom marshaling, and Go's type system provides powerful tools for handling complex data transformations while maintaining type safety and performance.

Remember that the key to effective JSON handling in Go is understanding when to use struct tags versus custom marshaling, how to handle optional and nullable fields properly, and how to structure your data types to match your API contracts. With these techniques in your toolkit, you'll be well-equipped to handle any JSON processing challenge that comes your way.

As you continue working with JSON in Go, experiment with different approaches, profile your applications for performance bottlenecks, and always consider the maintainability and readability of your code. The patterns and techniques covered in this guide will serve as a solid foundation for building scalable and maintainable Go applications.