Skip to content

Read configuration values

Assumed setup

Every snippet below assumes a store and a ctx:

ctx := context.Background()

store, err := config.NewStore(ctx,
    config.WithFiles(config.OS(), "/etc/app/config.yaml"),
    config.WithEnv("APP"),
)
if err != nil {
    return err
}

See Load & merge configuration for the full set of options.

Every read goes through a View, which resolves values from one immutable snapshot and performs no I/O. Taking one is a pointer copy, so take them freely:

view := store.View()

Read a scalar

host := view.GetString("server.host")
port := view.GetInt("server.port")
debug := view.GetBool("log.debug")
wait := view.GetDuration("client.timeout")   // "30s", "1h15m"

An absent key returns the zero value rather than an error. If you need to tell absent from set to zero, ask:

if !view.Has("server.port") {
    return errors.New("server.port is not configured")
}

IsSet is the same question. SectionExists("server") asks it of a subtree without decoding one.

Read the type you actually want

There is a named accessor for the common widths and shapes — GetInt32, GetInt64, GetUint through GetUint64, GetFloat64, GetTime, GetStringSlice, GetIntSlice, GetStringMap, GetStringMapString, GetStringMapStringSlice.

For anything else, use config.Value[T], which reads any type at all:

addr, err := config.Value[netip.Addr](view, "server.bind")
hosts, err := config.Value[[]string](view, "upstream.hosts")
limits, err := config.Value[map[string]int](view, "quotas")

It is a function rather than a method because Go does not allow type parameters on methods — the same reason UnmarshalSection and ValidateStruct are functions. It takes a Reader, so it works against a View, an Observed handed to an observer, or a mock.

MustValue[T] is the panic-on-error variant, for package initialisation and tests.

Types that decode without you doing anything

Beyond the Go built-ins, these decode from their ordinary written form:

Written as Decodes to
30s, 1h15m time.Duration
10.0.0.1 netip.Addr, net.IP
10.0.0.0/8 netip.Prefix, *net.IPNet
10.0.0.1:8080 netip.AddrPort
https://example.com *url.URL (pointer form)
Europe/London *time.Location
a,b,c []string

And any type of your own implementing encoding.TextUnmarshaler. That is how Go expresses an enum or a domain type, so your types decode without this module having heard of them:

type Severity int

const (
    Debug Severity = iota
    Info
)

func (s *Severity) UnmarshalText(text []byte) error {
    switch string(text) {
    case "debug":
        *s = Debug
    case "info":
        *s = Info
    default:
        return fmt.Errorf("unknown severity %q", text)
    }

    return nil
}

Then read it like anything else:

// log.level: debug
level, err := config.Value[Severity](view, "log.level")

The same hooks are used when decoding a struct, so a Severity field inside a typed section works the same way.

Read a size

GetSizeInBytes reads values written the way an operator writes them:

// max_upload: 10mb
n := view.GetSizeInBytes("max_upload")   // 10485760

Units are binary — kb, mb, gb, tb are powers of 1024. A bare number is a count of bytes. Anything unparseable reads as 0, as the other accessors return their zero value.

Scope reads to a subtree

Sub returns a view rooted at a key, so a component can be handed the part of the configuration that concerns it and nothing else:

if db := view.Sub("database"); db != nil {
    dsn := db.GetString("dsn")      // reads database.dsn
}

It returns nil when the key is absent — check it. Provenance still reports the full path, so a scoped view does not lose track of where its values came from.

Decode a whole struct

type AppSettings struct {
    Server ServerSettings `mapstructure:"server"`
    Log    LogSettings    `mapstructure:"log"`
}

var settings AppSettings

if err := view.Unmarshal(&settings); err != nil {
    return err
}

UnmarshalKey("server", &srv) does the same for one subtree. For a typed view that stays current across reloads, use typed sections instead.

Enumerate what is there

for _, key := range view.Keys() {
    fmt.Println(key, "=", view.Get(key))
}

Keys returns every leaf path, sorted, including keys whose value is an empty container. store.Snapshot().Values() returns the whole merged tree as a map — a copy, so mutating it cannot affect the store.

Find what is set in more than one place

Keys tells you what exists. Shadows tells you what is set more than once, and which copy is losing:

for _, sh := range view.Shadows() {
    fmt.Printf("%s = from %s, shadowing %d\n",
        sh.Path, sh.InEffect.Name, len(sh.Shadowed))
}
server.port = from project, shadowing 1
token = from APP_TOKEN, shadowing 2

Only paths that more than one layer defines appear, so there is nothing to filter. It reports leaves only — a populated subtree has no single winning layer to name — and it carries paths and sources, never values.

Through a scoped view the paths are scoped too, so you can hand one straight back:

srv := view.Sub("server")
for _, sh := range srv.Shadows() {
    fmt.Println(sh.Path, srv.Get(sh.Path)) // "port", 2
}

Keep a set of reads consistent

A View is already pinned to one snapshot, so reads through it cannot straddle a reload. When several values must agree and you would otherwise take more than one view, pin one explicitly:

var (
    host string
    port int
)

err := store.With(func(view *config.View) error {
    host = view.GetString("server.host")
    port = view.GetInt("server.port")

    return nil
})
if err != nil {
    return err
}