Boolean config fields cannot be overridden from the environment
What happened:
The kratos config/env Source emits every value as a string, and on Scan a string is rejected for a bool field. A single boolean override therefore aborts the config load:
json: cannot unmarshal string into Go struct field Bootstrap.debug of type boolWith a protobuf Bootstrap it is narrower and more surprising, because protojson accepts a quoted string for every other scalar kind. Only bool fails:
| Override | Result |
|---|---|
log.level=debug (string) |
ok |
...busy_timeout=5s (Duration) |
ok |
...mode=MODE_FILE (enum) |
ok |
...debug=true (bool) |
proto: invalid value for bool field debug: "true" |
What you expected to happen:
APP_debug=true should set the field, the same way APP_level=debug already does.
Scan(v) already knows the destination, so the types are available without guessing them from the text. The natural fix is for the reader to coerce string leaves to the destination's type at scan time via the protobuf descriptor for a proto.Message, or the struct field type otherwise.
If that is too broad, an exported hook would be enough: a supported way for a Source or decoder to emit typed values, so this can be handled in a few lines outside the framework rather than by reimplementing the env source.
Please do not fix it by inferring types from the text alone ("true" → bool, "1234" → number).
That mistypes string fields whose values happen to look numeric, which is common for passwords, ports written as strings, and version numbers, and it fails on the deployment whose credentials happen to be digits rather than in development.
How to reproduce it (as minimally and precisely as possible):
Self-contained, no protoc required:
package main
import (
"fmt"
"os"
"path/filepath"
"github.com/go-kratos/kratos/v3/config"
"github.com/go-kratos/kratos/v3/config/env"
"github.com/go-kratos/kratos/v3/config/file"
)
type Bootstrap struct {
Debug bool `json:"debug"`
Port int `json:"port"`
Level string `json:"level"`
}
func load(dir string, opts ...config.Option) (Bootstrap, error) {
opts = append([]config.Option{
config.WithSource(file.NewSource(dir), env.NewSource("APP_")),
}, opts...)
c := config.New(opts...)
defer c.Close()
if err := c.Load(); err != nil {
return Bootstrap{}, err
}
var bc Bootstrap
return bc, c.Scan(&bc)
}
func main() {
dir, _ := os.MkdirTemp("", "conf")
defer os.RemoveAll(dir)
os.WriteFile(
filepath.Join(dir, "config.yaml"),
[]byte("debug: false\nport: 8000\nlevel: info\n"),
0o600,
)
report := func(name string, bc Bootstrap, err error) {
if err != nil {
fmt.Printf("%-32s FAIL %v\n", name, err)
return
}
fmt.Printf("%-32s ok %+v\n", name, bc)
}
os.Setenv("APP_level", "debug")
bs, err := load(dir)
report("string override", bs, err)
os.Unsetenv("APP_level")
os.Setenv("APP_debug", "true")
bs, err = load(dir)
report("bool override", bs, err)
bs, err = load(dir, config.WithResolveActualTypes(true))
report("bool + WithResolveActualTypes", bs, err)
}Produce output:
$ # copy file above to main.go
$ go mod init kratos.repo
$ go mod tidy
$ go run ./main.go
string override ok {Debug:false Port:8000 Level:debug}
2026/08/26 12:56:36 INFO watcher's ctx cancel error="context canceled"
2026/08/26 12:56:36 INFO watcher's ctx cancel error="context canceled"
2026/08/26 12:56:36 INFO watcher's ctx cancel error="context canceled"
bool override FAIL json: cannot unmarshal string into Go struct field Bootstrap.debug of type bool
2026/08/26 12:56:36 INFO watcher's ctx cancel error="context canceled"
2026/08/26 12:56:36 INFO watcher's ctx cancel error="context canceled"
bool + WithResolveActualTypes FAIL json: cannot unmarshal string into Go struct field Bootstrap.debug of type bool
2026/08/26 12:56:36 INFO watcher's ctx cancel error="context canceled"
string override ok {Debug:false Port:8000 Level:debug}
bool override FAIL json: cannot unmarshal string into Go struct field Bootstrap.debug of type bool
bool + WithResolveActualTypes FAIL json: cannot unmarshal string into Go struct field Bootstrap.debug of type boolAnything else we need to know?:
Doesn't #3306 doesn't already cover this?
No, #3306 added convertToType, but it is only reachable from inside the loop over placeholder matches in expand:
func expand(s string, mapping func(string) string, toType bool) any {
re := placeholderRegexp.FindAllStringSubmatch(s, -1)
var ct any
for _, i := range re {
if len(i) == 2 {
m := mapping(i[1])
if toType {
ct = convertToType(m)
return ct
}
s = strings.ReplaceAll(s, i[0], m)
}
}
return s
}A plain env value such as true contains no ${...}, so re is empty, the
body never runs, and the string is returned unchanged.
The conversion itself works, it is just gated behind placeholder syntax.
debug: ${DEBUG_FLAG:false} in the config file with WithResolveActualTypes(true) does produce a real bool. So the machinery already exists; it simply isn't applied to values arriving directly from a source.
Enabling that option is not a usable workaround, because it also changes two unrelated behaviors:
addr: "0.0.0.0:${PORT:8000}"becomes the number8000. Thereturnabove discards the rest of the string, so interpolation stops working.name: "${SVC:1234}"into a string field becomes the number1234and is rejected, because the conversion is by value shape rather than by destination type.
Environment:
- Kratos version (use
kratos -v):kratos version v3.0.0(modulegithub.com/go-kratos/kratos/v3 v3.0.0) - Go version (use
go version):go version go1.26.5 darwin/arm64 - OS (e.g:
cat /etc/os-release): macOS 26.5.2 (build 25F84),Darwin 25.5.0 arm64
Source: go-kratos/kratos