caddyserver/caddy · error
%s: %s field must be a string or number
Error message
%s: %s field must be a string or number
What it means
indexConfigObjects recursively walks the decoded JSON config and registers '@id' fields. When an '@id' value decodes to something other than string or float64 (JSON numbers decode as float64) — i.e. it was an object, array, boolean, or null — the walk aborts with '<path>: @id field must be a string or number', naming the exact config path of the offending node. Surfaced through 'indexing config' during config changes.
Source
Thrown at caddy.go:302
}
// indexConfigObjects recursively searches ptr for object fields named
// "@id" and maps that ID value to the full configPath in the index.
// This function is NOT safe for concurrent access; obtain a write lock
// on currentCtxMu.
func indexConfigObjects(ptr any, configPath string, index map[string]string) error {
switch val := ptr.(type) {
case map[string]any:
for k, v := range val {
if k == idKey {
var idStr string
switch idVal := v.(type) {
case string:
idStr = idVal
case float64: // all JSON numbers decode as float64
idStr = fmt.Sprintf("%v", idVal)
default:
return fmt.Errorf("%s: %s field must be a string or number", configPath, idKey)
}
if existingPath, ok := index[idStr]; ok {
return fmt.Errorf("duplicate ID '%s' found at %s and %s", idStr, existingPath, configPath)
}
index[idStr] = configPath
continue
}
// traverse this object property recursively
err := indexConfigObjects(val[k], path.Join(configPath, k), index)
if err != nil {
return err
}
}
case []any:
// traverse each element of the array recursively
for i := range val {
err := indexConfigObjects(val[i], path.Join(configPath, strconv.Itoa(i)), index)
if err != nil {View on GitHub (pinned to 50e54ee279)
Solutions
- Go to the config path in the error message and change that '@id' to a plain unique string.
- Grep your JSON for '"@id"' followed by {, [, true, false, or null.
- If the ID must carry structure, flatten it into the string (e.g. "group:item").
Example fix
// before
"handle": { "@id": { "app": "foo" } }
// after
"handle": { "@id": "foo" } Defensive patterns
Strategy: type-guard
Type guard
func validIDType(v any) bool {
switch v.(type) {
case string, float64:
return true
}
return false
} Prevention
- Keep @id values as plain strings.
- In generators, render @id with %s formatting of a string variable.
- Scan JSON for '"@id":' followed by non-scalar values before submitting.
When it happens
Trigger: JSON config containing "@id": {"name":"x"}, "@id": [1], "@id": true, or "@id": null at any depth; PUT/PATCH to /config/ that writes such a value; template engines rendering @id as a nested structure.
Common situations: Hand-edited JSON configs; YAML->JSON converters emitting booleans for flags mistakenly placed under @id; copy-paste where an @id block was pasted inside another @id.
Related errors
- indexing config: %v
- duplicate ID '%s' found at %s and %s
- parsing listener address: %v
- must be exactly one listener address; cannot listen on: %s
- %v; additionally, restoring old config: %v
AI-assisted analysis of caddyserver/caddy@50e54ee279 (2026-08-15).
Data as JSON: /api/errors/ed3f637094cce07f.
Report an issue: GitHub.