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

  1. Go to the config path in the error message and change that '@id' to a plain unique string.
  2. Grep your JSON for '"@id"' followed by {, [, true, false, or null.
  3. 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

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


AI-assisted analysis of caddyserver/caddy@50e54ee279 (2026-08-15). Data as JSON: /api/errors/ed3f637094cce07f. Report an issue: GitHub.