Enums in Go Done Properly With Constants and Methods
A complete Go enum has a named type, constants, String, Parse, Valid and text marshaling. See int and string versions and when to pick each.

An enum is a closed set of named values, and Go gives you the pieces without the package. You build one yourself, every Go project builds the same few lines, and the difference between a sloppy enum and a careful one is five methods. This how-to builds the careful version for a Status type, shows the string-backed alternative, and covers the places where enums go wrong in production: values that pass the compiler and not the sense check, numbers that change meaning in storage and switches that miss a case. The mechanics of iota are in constants and iota, and the Stringer generator is in the next article. This article belongs in the Go basics path.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
The complete int-backed enum
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
)
type Status int
const (
StatusUnknown Status = iota
StatusActive
StatusSuspended
StatusClosed
statusEnd
)
var statusNames = [...]string{
StatusUnknown: "unknown",
StatusActive: "active",
StatusSuspended: "suspended",
StatusClosed: "closed",
}
// Statuses lists every valid value, for validation and help text.
func Statuses() []Status {
return []Status{StatusActive, StatusSuspended, StatusClosed}
}
func (s Status) Valid() bool { return s > StatusUnknown && s < statusEnd }
func (s Status) String() string {
if s < 0 || s >= statusEnd {
return fmt.Sprintf("Status(%d)", int(s))
}
return statusNames[s]
}
func ParseStatus(name string) (Status, error) {
for _, s := range Statuses() {
if s.String() == name {
return s, nil
}
}
return StatusUnknown, fmt.Errorf("unknown status %q", name)
}
func (s Status) MarshalText() ([]byte, error) {
if !s.Valid() {
return nil, fmt.Errorf("invalid status %d", int(s))
}
return []byte(s.String()), nil
}
func (s *Status) UnmarshalText(b []byte) error {
parsed, err := ParseStatus(string(b))
if err != nil {
return err
}
*s = parsed
return nil
}
type Account struct {
Name string `json:"name"`
Status Status `json:"status"`
}
func main() {
fmt.Println(StatusActive, Status(9), StatusUnknown.Valid(), StatusClosed.Valid())
out, err := json.Marshal(Account{"ada", StatusSuspended})
fmt.Println(string(out), err)
var a Account
fmt.Println(json.Unmarshal([]byte(`{"name":"grace","status":"closed"}`), &a), a.Status)
fmt.Println(json.Unmarshal([]byte(`{"name":"x","status":"paused"}`), &a))
_, err = json.Marshal(Account{"bad", Status(42)})
fmt.Println(err)
var zero Account
fmt.Println(zero.Status, zero.Status.Valid())
fs := flag.NewFlagSet("app", flag.ContinueOnError)
fs.SetOutput(io.Discard)
chosen := StatusActive
fs.TextVar(&chosen, "status", chosen, "account status")
fmt.Println(fs.Parse([]string{"-status", "closed"}), chosen)
fmt.Println(fs.Parse([]string{"-status", "bogus"}))
}active Status(9) false true
{"name":"ada","status":"suspended"} <nil>
<nil> closed
unknown status "paused"
json: error calling MarshalText for type main.Status: invalid status 42
unknown false
<nil> closed
invalid value "bogus" for flag -status: unknown status "bogus"Follow the output line by line. String prints names, and an out-of-range value prints Status(9) instead of panicking. Valid rejects the unknown zero value and the sentinel at the end. MarshalText writes the name into JSON, and an invalid value fails to encode with a clear error, so a bug that produces Status(42) cannot silently reach a database. UnmarshalText accepts a known name and rejects paused, naming the bad value. A zero Account has StatusUnknown, which is not valid, so code that forgot to set the status is caught by Valid and not treated as the first real choice.
The pieces, and why each is there:
- A named type.
Statusis distinct fromint, so a function that takes aStatuswill not accept an arbitrary number without a conversion, as type conversion explains. - Zero means unknown. The first constant is a placeholder that no real value can be, following the zero value rule.
- A sentinel at the end.
statusEndmarks where the valid range stops, soValidandStringstay correct when you add a value above it. - One list of values.
Statuses()is the single source for parsing, help text and tests, so a new constant needs one new line there. - Text marshaling. Implementing
MarshalTextandUnmarshalTextmakes the encoder write names in JSON, YAML (with most libraries),flagvalues and map keys. The numbers are then free to change.
A string-backed enum
The alternative stores the name itself:
package main
import (
"fmt"
"slices"
)
type Color string
const (
Red Color = "red"
Green Color = "green"
Blue Color = "blue"
)
var colors = []Color{Red, Green, Blue}
func (c Color) Valid() bool { return slices.Contains(colors, c) }
func ParseColor(s string) (Color, error) {
c := Color(s)
if !c.Valid() {
return "", fmt.Errorf("unknown color %q", s)
}
return c, nil
}
func main() {
fmt.Println(Red, Color("purple").Valid())
fmt.Println(ParseColor("green"))
fmt.Println(ParseColor("mauve"))
var unset Color
fmt.Printf("%q %t\n", unset, unset.Valid())
}red false
green <nil>
unknown color "mauve"
"" falseNo String method is needed, because the value is its own name, and JSON, SQL and logs already show it. The zero value is the empty string, which is not in the list, so Valid rejects it and "unset" is again distinguishable from a real choice. Reordering or inserting constants cannot change a stored value, since there are no numbers, and a database column reads suspended and not 2. The costs are memory, a string header of 16 bytes against 8 for an int, and speed, since comparing strings is slower than comparing integers, which matters in a hot loop and not in business code. Color("purple") compiles, so, as with the int version, the Valid check at the boundary is the guard.
The same methods serve flags and databases
Because the enum implements encoding.TextMarshaler and TextUnmarshaler, it plugs into more than JSON. The flag package's TextVar, added in Go 1.19, binds a flag to any such type, and the last lines of the program above show it accepting closed and rejecting bogus with an error that carries the enum's own message. Command line help and configuration files get the same validation for free.
For SQL, database/sql wants the driver.Valuer and sql.Scanner interfaces. For a string-backed or name-backed enum, the implementation is a few lines each: Value returns s.String(), and Scan accepts a string or []byte and calls ParseStatus. Storing the name keeps the column readable and the rows meaningful when someone queries them by hand, and a database CHECK constraint or an enum column type can enforce the same list on the server side. The one thing to decide is what the database does with an unknown value that was written by a newer version of the program: failing loudly in Scan is safer than mapping it to StatusUnknown and hiding the mismatch.
Choosing between them
| Concern | int with iota | string constants |
|---|---|---|
| Stored in a database or sent over the wire | Needs MarshalText and stable numbers | Works as is |
| Used as an index into an array of data | Natural | Needs a map |
| Compared in a hot path | Fastest | Slower |
| Readable in a debugger and in logs | Needs String | Already readable |
| Reordering the source | Changes the numbers | Safe |
| Bit flags | Yes, with shifts | No |
I default to string-backed enums for anything that leaves the process, and to int-backed with text marshaling when the value is used heavily in memory. There is a third option that I rarely use, a struct type with unexported fields and exported variables for the values, which makes it impossible to construct an invalid value from outside the package. It gives the strongest guarantee, since Status(42) does not compile, and it costs the ability to use the values in a switch over constants and as map keys without extra care. For most code the Valid check at the boundary is enough protection. Whichever you choose, keep the number stable by adding explicit values or by never storing the integer.
Test that every value is covered
A test over the list of all values catches a constant that was added without a name or a parse entry:
package status
import "fmt"
type Status int
const (
Unknown Status = iota
Active
Suspended
Closed
end
)
var names = [...]string{Unknown: "unknown", Active: "active", Suspended: "suspended", Closed: "closed"}
func All() []Status { return []Status{Active, Suspended, Closed} }
func (s Status) String() string {
if s < 0 || s >= end {
return fmt.Sprintf("Status(%d)", int(s))
}
return names[s]
}
func Parse(name string) (Status, error) {
for _, s := range All() {
if s.String() == name {
return s, nil
}
}
return Unknown, fmt.Errorf("unknown status %q", name)
}package status
import "testing"
func TestEveryValueRoundTrips(t *testing.T) {
if len(names) != int(end) {
t.Fatalf("names has %d entries for %d statuses", len(names), end)
}
for _, s := range All() {
got, err := Parse(s.String())
if err != nil || got != s {
t.Errorf("Parse(%q) = %v, %v; want %v", s.String(), got, err, s)
}
}
if _, err := Parse("nope"); err == nil {
t.Error("Parse accepted an unknown name")
}
}=== RUN TestEveryValueRoundTrips
--- PASS: TestEveryValueRoundTrips (0.00s)
PASS
ok example.test/lab 1.273sThe first check compares the length of the names array with the number of values, which fails when you add a constant and forget its name. The loop proves that String and Parse agree for every valid value. That is the whole safety net for the enum, and it is short enough to copy into each new one. For a switch over an enum, the exhaustive linter flags a missing case, and without a linter the discipline is a default that panics or returns an error. The article on the Stringer generator removes the hand-written names, and the Go blog on constants and the specification's iota section cover the mechanics the enum is built on.


