defaults: filling a Go struct from struct tags
Initialize structs with default values
At a glance
- What is it?
- defaults reads default values out of struct tags and applies them to a zero-valued struct, and the README is unusually honest about the edges: a scalar zero cannot be told from an unset value, integer tags are Go literals while slice and map tags are JSON, and a duration only parses as a duration when the field is exactly time.Duration. The project holds its coverage gate at 100 percent.
- Who is it for?
- Adopt defaults when your Go configuration structs need declared defaults, because the tag syntax is the one your structs already carry and the package handles the recursive cases, nested maps of structs and aliased types, that hand-written initialisers get wrong.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 37 days ago.
- What is it written in?
- Mainly Go, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
One tag, one call, and a lot of edge cases
defaults is a small Go library whose job is to fill a struct with the values written in its tags:
type Server struct {
Host string `default:"localhost"`
Port int `default:"8080"`
Timeout time.Duration `default:"30s"`
Tags []string `default:"[\"web\"]"`
Labels map[string]string `default:"{\"env\": \"dev\"}"`
}
var server Server
if err := defaults.Set(&server); err != nil {
panic(err)
}
// localhost:8080 30s [web] map[env:dev]Installing it is a single go get github.com/creasty/defaults, and the module requires Go 1.22 with testify as its only direct dependency.
What makes it worth reading rather than skimming is the type coverage. Almost all kinds of types are supported: the scalars including int, uint, their sized variants, float32 and float64, uintptr, bool and string; the complex ones, map, slice and struct; nested combinations such as map[K1]map[K2]Struct and []map[K1][]Struct; aliased types, time.Duration and a type like type Enum string; and pointer types, both *SampleStruct and *int. Fields are initialised recursively, and non-initial values are preserved rather than reset.
Defaults can also be dynamic. A field can implement the defaults.Setter interface, or implement encoding.TextUnmarshaler or json.Unmarshaler, and those unmarshalers take precedence over defaults.Setter, because a field's own tag is handed to UnmarshalText or UnmarshalJSON, and when one of them takes the tag, SetDefaults is not called for that field.
The public API is three functions: Set, MustSet which is the same but panics, and CanUpdate. Runnable examples for Set and CanUpdate are on pkg.go.dev, and the package example there calls MustSet.
The zero-value problem, and why pointers are the answer
The behaviour section starts with the rule that everything else follows from: a field is written only while it still holds its type's zero value.
The reason is that no scalar type can tell an unspecified value from its zero value. An int left alone is 0, a string is an empty string, a bool is false. A zero the caller set on purpose is indistinguishable from one that was never set, so the default replaces it. This is the classic problem with default values in any language, and the way out is to change the representation rather than to add clever logic.
The way out here is a pointer. nil means unspecified, and a pointer to a zero scalar is preserved, so *bool is the way to let false survive a default of true:
type Feature struct {
Enabled bool `default:"true"` // a caller's false is replaced
Verbose *bool `default:"true"` // a caller's &false survives
}The pointer rules are not uniform, and the README is precise about where the walk stops. A pointer to a struct is descended into like the struct itself, so the struct's zero fields still get their defaults. A pointer the caller allocated to anything else is not: neither its tag nor the walk goes past it, so the elements behind a caller's `*[]T` or `*map[K]T`, and the struct behind a pointer to a pointer, get no defaults, and a default that would fail there is not reported. The same `*[]T` or `*map[K]T` held as a map value is descended into, though a pointer to a pointer held as one is not.
There is also an opt-out, default:"-", which means the field is neither parsed nor recursed into. That is how a field gets its value from a SetDefaults method instead of a tag.
Integers are Go literals, slices and maps are JSON
The parsing rules differ by kind, and the difference is not arbitrary.
An integer tag is a Go integer literal. The 0x, 0o and 0b prefixes and the _ separator all work, and so does the legacy octal form with a leading zero, which means default:"0644" is 420, while default:"08080" is an error because 8 is not a valid octal digit. That is the behaviour of Go's own literal syntax, applied so that a file mode written in a tag means what it means everywhere else in Go.
Slice and map tags are JSON instead, because those tags have to express a structure rather than a number. And a JSON number rejects a leading zero, so the same 0644 written as a slice or map tag is not valid. The README also notes a detail about map keys: an integer map key, which in JSON is a string, is read as decimal, so {"010": ...} is key 10 rather than key 8.
So the same number can be valid in one tag and invalid in another depending on the field's type, and the error you get differs. That is a real footgun, and it is one of the reasons to keep configuration structs in one style rather than mixing durations, modes and lists across a codebase.
The struct and map and slice tags in the first example follow the same rule, which is why the Tags and Labels fields carry JSON with escaped quotes.
A time.Duration is an int64 at runtime, and that shows
The duration rule is the one most likely to surprise you, and the README explains the reasoning in full.
A time.Duration field takes anything time.ParseDuration accepts. Every field that is an int64 underneath is parsed the same way, though, which produces the asymmetry in this example:
type Job struct {
Timeout time.Duration `default:"1"` // 1ns, not 1s
Retries int64 `default:"5m"` // 300000000000, not an error
}A duration in a time.Duration field is 30s, and a bare 1 in the same field is one nanosecond, because ParseDuration reads a bare number as nanoseconds. A duration string in a plain int64 field is 300000000000, because the int64 is parsed the same way and ParseDuration accepts a unit.
The design decision behind that is stated as deliberate: a type defined from time.Duration keeps no trace of it at runtime, so parsing durations only for time.Duration itself would stop such types from taking "10s". In other words, if you declare type Timeout time.Duration, the runtime cannot tell it apart from an int64, so the library does not try to.
The practical advice is to write duration strings with an explicit unit everywhere, including in time.Duration fields. The bare number works, but it is a nanosecond count written as if it were seconds, and nothing in the code will warn you.
Unmarshalers take precedence, and {} and [] are special
The unmarshaler rules decide who gets to interpret a tag, and they have a few sharp edges.
The precedence of UnmarshalText and UnmarshalJSON over SetDefaults covers a tag handed to them and taken. A tag they reject is parsed by the field's kind instead, so a struct parsed that way gets its own field tags and SetDefaults as usual. The failure is therefore recoverable: an unmarshaler that refuses a value does not break the field, it just declines.
The special cases are the empty object and the empty array. {} and [] are never handed to UnmarshalJSON directly. {} allocates an empty struct or a map, and [] an empty slice. In the other direction, [] on a struct or a map, and {} on a slice, are parsed through encoding/json, which may call UnmarshalJSON on the way without that counting as taking the tag.
One more case matters for nested values. A value built from a parent's JSON tag, such as an element of a slice, is filled like any other, whatever unmarshaler encoding/json ran while decoding it. So a slice of structs decoded from a parent tag does not escape the defaulting pass.
The practical consequence is that the order of interpretation is fixed and documented: unmarshaler first, then the field's kind, then SetDefaults. If you implement an unmarshaler on a type, you are taking over the meaning of its tag, and defaults.Set will not call your SetDefaults method for it.
Shared values, cycles, and the one place Set writes to a map
The behaviour section closes with three rules about identity, and they are the ones that bite in production rather than in a test.
First, Set keeps track of the values on the path it is walking, not of every value it has walked. A value reachable by more than one path, whether two pointers to one struct or a map two fields hold, is filled on each path and its SetDefaults is called on each. So a setter that is not idempotent applies itself more than once, and a chain of values each shared by two pointers takes time that doubles with every link. That is exponential behaviour in the depth of the sharing, and it is the kind of thing that shows up as a slow configuration load rather than as a crash.
Second, cycles terminate. Data with a cycle, such as a pointer back up to a parent, ends: where the path leads back to a value still being walked, Set goes no further, and the walk already under way finishes that value. So a self-referential structure is filled once and then left alone, which is the only reasonable behaviour.
Third, there is exactly one place where Set writes to a map. A struct held as a map value cannot be filled in place, so Set fills a copy and stores it back under its key, and it does that for every such struct it walks, whether or not anything changed. That store is a write to the map, so the README says not to call Set while another goroutine reads a map of structs it walks, even one with nothing left to fill. No other map value is stored back, so Set only reads a map of anything else. A slice or map held as a map value is filled through, as is a pointer to a struct, a slice or a map, while a pointer to anything else, such as a pointer to a pointer, is not descended into.
The error behaviour is simple and stated up front: Set stops at the first field whose default fails and returns an error naming it, and a value Set found zero on the way to that default is put back to zero.
The test suite is organised by behaviour, and coverage is a gate
The repository layout is a list of the behaviours above, one test file each, which is the clearest possible documentation of the semantics: set_pointer_test.go, set_map_test.go, set_unmarshaler_test.go, set_shared_test.go, set_cycle_test.go, set_duration_test.go, set_error_test.go, set_optout_test.go, set_preserve_test.go, set_recursion_test.go, set_scalar_test.go, set_slice_test.go, set_struct_test.go and set_nesting_test.go, alongside canupdate.go, method.go, mustset.go, path.go, set.go, setter.go and unmarshal.go.
The Makefile shows the project's own standards, and they are strict ones. Tests run with -race -shuffle=on, so the order of tests is randomised and the race detector is on. The coverage target computes the total and compares it against COVERAGE_MIN, which is set to 100.0, and exits with an error if the total is below it. Linting and formatting both go through golangci-lint, and there is a .golangci.yml alongside a codecov.yml.
There are four benchmark targets, which is more attention to performance than most libraries of this size give. The plain bench target runs the benchmarks, bench-smoke runs them with a benchtime of 1x so a broken benchmark fails fast in CI, and bench-compare takes a BASE, ROUNDS, BENCH and BENCHTIME and runs them through scripts/bench-compare.sh, with a comment in the Makefile explaining that the $(value ...) form is used so a variable is passed as typed rather than being expanded, because a plain $(BASE) would expand a $ inside a pattern such as '^BenchmarkParse$'.
The release history is fast: v1.9.0 on 2026-09-12, v1.10.0 on 2026-09-13 and v1.11.0 on 2026-09-16, with the last push on 2026-09-16. The licence is MIT, the default branch is master, and the project also carries a CLAUDE.md alongside its README, which is a sign that it is maintained with the same care as the code.
Editorial conclusion
Adopt defaults when your Go configuration structs need declared defaults, because the tag syntax is the one your structs already carry and the package handles the recursive cases, nested maps of structs and aliased types, that hand-written initialisers get wrong. Do not use it where an unset value must be distinguishable from a deliberately set zero, because a scalar zero is indistinguishable from an unset one and the default overwrites it, and use a pointer where that distinction matters. Before you adopt it, read the duration rule, since a bare integer in a time.Duration tag is nanoseconds while the same string in an int64 tag is a duration, check the unmarshaler precedence for any type of yours that implements UnmarshalText or UnmarshalJSON, and make sure you never call Set on a map of structs while another goroutine reads that map, because the library writes those maps back.
Frequently asked questions
How do I set default values for a Go struct?
Add a default tag to the fields and call defaults.Set with a pointer to the struct, for example defaults.Set(&server). The API is three functions: Set, MustSet which panics on error, and CanUpdate, with runnable examples on pkg.go.dev.
Does defaults overwrite a value I already set?
A field is written only while it still holds its type's zero value, so any value you set is preserved. The exception is a zero value you set deliberately, which is indistinguishable from an unset one, so the default replaces it. Use a pointer, where nil means unspecified, to let false survive a default of true.
How are integer and duration tags parsed?
Integer tags are Go integer literals, so 0x, 0o, 0b and the legacy leading-zero octal form work, with 0644 meaning 420. Slice and map tags are JSON instead, where a leading zero is rejected. A time.Duration takes anything time.ParseDuration accepts, and since every int64 underneath is parsed the same way, a bare 1 in a duration field is one nanosecond while a duration string in an int64 field is accepted.
Can I use defaults with types that implement UnmarshalText or UnmarshalJSON?
Yes, and the unmarshaler takes precedence over the defaults.Setter interface, because the field's own tag is handed to UnmarshalText or UnmarshalJSON and SetDefaults is then not called for that field. A tag the unmarshaler rejects is parsed by the field's kind instead, so the field still gets its own tags and SetDefaults as usual.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/creasty-defaults)Was this useful?