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:
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:
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:
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:
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:
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:
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:
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:
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.
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
orffjson
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.