The JSON mismatch
You define a status enum in Go. You send it to an API. The API rejects the payload. You look at the JSON. The spec says the status field should be "active". Your code sent {"status": 1}. The client crashes. Or worse, the client accepts the number, and a week later a support ticket arrives because the dashboard displays "1" instead of "Active".
Go enums are just type aliases. If you write type Status int, the compiler treats Status as a distinct type for type safety, but the underlying storage is an integer. The encoding/json package respects the underlying type by default. It sees an integer and writes an integer. It sees a string and writes a string. The friction starts when you want the safety and convenience of an integer enum in Go code, but the API contract demands a human-readable string in JSON.
Enums are just types
Go does not have a special enum keyword. An enum is a named type backed by a primitive, usually int or string, with a set of constants.
type Status int
const (
StatusPending Status = iota
StatusActive
StatusClosed
)
The iota generator assigns 0, 1, and 2 to the constants. The type Status prevents accidental comparison with a raw int. You cannot pass a Status to a function expecting an int without an explicit conversion. This is good. It catches bugs at compile time.
The JSON encoder does not care about the name Status. It cares about the value. When you marshal a Status, the encoder checks if the type implements the json.Marshaler interface. If it does, the encoder calls the method and uses the result. If it does not, the encoder falls back to reflection. Reflection reveals the underlying type. For type Status int, the underlying type is int. The encoder writes the integer to the output.
The same logic applies to unmarshaling. The decoder checks for the json.Unmarshaler interface. If missing, it reads the JSON token and converts it to the underlying type. A JSON number converts to an int. A JSON string converts to a string. A JSON string cannot convert to an int without a custom method.
Default behavior
Here's the default behavior. An integer enum marshals to a number. The output matches the constant value.
package main
import (
"encoding/json"
"fmt"
)
type Status int
const (
StatusPending Status = iota
StatusActive
StatusClosed
)
func main() {
// Marshal converts the Go value to JSON bytes.
// Since Status is an int, the output is the integer 1.
data, _ := json.Marshal(StatusActive)
fmt.Println(string(data))
}
# output:
1
The encoder writes 1. No quotes. No string. If you unmarshal 1 back into a Status, it works. If you unmarshal "active", the decoder fails. The compiler does not complain. The error happens at runtime. The decoder returns an error like json: cannot unmarshal string into Go value of type main.Status.
Taking control with interfaces
To change the JSON representation, you implement the marshaler and unmarshaler interfaces. These interfaces live in the encoding/json package.
The json.Marshaler interface requires a method MarshalJSON() ([]byte, error). The method returns the JSON bytes and an error. The json.Unmarshaler interface requires a method UnmarshalJSON([]byte) error. The method takes the raw JSON bytes and returns an error.
Implementing these methods gives you full control. You can map integers to strings, validate values, or even change the structure of the JSON. The method must be exported. The name must start with a capital letter. The receiver name follows Go convention: one or two letters matching the type. Use s for Status, not this or self.
Implementing MarshalJSON
Here's how to make the enum serialize as a string. The method maps the integer value to a human-readable string.
// MarshalJSON implements json.Marshaler.
// It converts the integer enum to a JSON string.
func (s Status) MarshalJSON() ([]byte, error) {
switch s {
case StatusPending:
return json.Marshal("pending")
case StatusActive:
return json.Marshal("active")
case StatusClosed:
return json.Marshal("closed")
default:
// Return an error for invalid enum values.
return nil, fmt.Errorf("unknown status: %d", s)
}
}
The method uses a switch statement to match the constant. Each case calls json.Marshal on a string. This is important. json.Marshal handles escaping. If the string contains quotes or unicode, json.Marshal produces valid JSON. If you return []byte("\"active\"") manually, you risk escaping bugs. json.Marshal("active") returns []byte("\"active\"") safely.
The default case returns an error. This catches invalid enum values. If someone creates a Status with value 99, the marshaler fails. The error propagates to the caller. The caller should handle the error. The community accepts the if err != nil { return err } boilerplate because it makes the unhappy path visible.
Implementing UnmarshalJSON
Here's the reverse operation. The method parses a JSON string and sets the receiver to the matching constant.
// UnmarshalJSON implements json.Unmarshaler.
// It parses a JSON string and sets the receiver to the matching constant.
func (s *Status) UnmarshalJSON(data []byte) error {
// Decode the JSON token into a temporary string first.
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
switch str {
case "pending":
*s = StatusPending
case "active":
*s = StatusActive
case "closed":
*s = StatusClosed
default:
return fmt.Errorf("unknown status: %q", str)
}
return nil
}
The receiver is a pointer. The compiler rejects a non-pointer receiver with method has pointer receiver type *Status but value has type Status. The decoder needs to modify the value, so it requires a pointer.
The method decodes the JSON token into a temporary string. This handles the JSON syntax. The input data is raw bytes. It might be "active" or "active". json.Unmarshal strips the quotes and handles escaping. The switch statement matches the string to the constant. The default case returns an error for unknown strings. This prevents silent failures. If the JSON contains "status": "unknown", the decoder returns an error. The caller knows the input is invalid.
Pitfalls and traps
Custom marshaling introduces new failure modes. The most common is infinite recursion.
If you call json.Marshal(s) inside MarshalJSON, where s is the receiver, the encoder calls MarshalJSON again. The method calls itself. The program panics with a stack overflow error. The runtime reports runtime: goroutine stack exceeds 1000000000-byte limit.
To break the cycle, cast the receiver to a type that does not implement the interface. A type alias works. type Alias Status creates a new type with the same underlying representation but no methods. json.Marshal(Alias(s)) uses the default behavior.
Another trap is omitempty. If you add the omitempty tag to a struct field, the encoder drops the field if the value is the zero value. For Status, the zero value is 0. If StatusPending is 0, the field vanishes.
type Task struct {
Name string `json:"name"`
Status Status `json:"status,omitempty"`
}
func main() {
// StatusPending is 0. omitempty treats 0 as empty.
task := Task{Name: "Init", Status: StatusPending}
data, _ := json.Marshal(task)
fmt.Println(string(data))
}
# output:
{"name":"Init"}
The status is gone. The client receives no status. This breaks the API contract. Do not use omitempty on enums that have a meaningful zero value. Drop the tag. Or use a pointer *Status. A pointer is nil by default. omitempty drops nil pointers. If the status is set, the pointer is not nil, and the field serializes. Pointers add indirection and nil checks. Weigh the trade-off.
Decision matrix
Pick the right approach for your use case.
Use a string-based enum (type Status string) when the JSON representation matches the Go constant exactly and you don't need integer arithmetic or iota convenience.
Use an integer-based enum with custom MarshalJSON when you want compact internal storage, numeric ordering, or iota generation, but the API requires readable strings.
Use the default integer marshaling when the consumer is another Go service or a system that expects numeric codes and performance matters more than readability.
Use json.RawMessage when the structure of the enum value varies or you need to defer parsing until later in the pipeline.
Enums are types. JSON is text. The interface bridges the gap.