docker/cli · error

top-level object must be a mapping

Error message

top-level object must be a mapping

What it means

Thrown by ParseYAML after it unmarshals the YAML bytes into a generic any and asserts the result is a map[string]any. Compose files must be a mapping at the top level (e.g. keyed by 'services:'); a YAML document whose root is a list, a scalar, or empty does not satisfy this and is rejected before any further parsing. This guards the contract that a compose file is an object of named sections, not a bare array or value.

Solutions

  1. Open the offending compose file and ensure the top-level is a mapping, e.g. start with 'services:' as the root key.
  2. If you intended a list of services, restructure it as 'services:\n name: {...}' so the list becomes named service entries.
  3. Run 'docker compose config' on the file to reproduce and get the exact file path in the error.
  4. Check that interpolation did not strip the entire document; ensure variables referenced by the top-level keys resolve.

Example fix

// before (broken - root is a list)
- web:
    image: nginx
// after
services:
  web:
    image: nginx
Defensive patterns

Strategy: validation

Validate before calling

// Validate a compose document is a top-level mapping before calling loader.Load.
func isTopLevelMapping(b []byte) error {
    var v any
    if err := yaml.Unmarshal(b, &v); err != nil {
        return fmt.Errorf("invalid yaml: %w", err)
    }
    if v == nil {
        return errors.New("compose file is empty")
    }
    if _, ok := v.(map[string]any); !ok {
        return errors.New("top-level yaml is not a mapping; expected keys like 'services:'")
    }
    return nil
}

Type guard

func isMappingDoc(b []byte) bool {
    var v any
    _ = yaml.Unmarshal(b, &v)
    _, ok := v.(map[string]any)
    return ok
}

Prevention

When it happens

Trigger: Calling loader.ParseYAML or loader.Load with a compose file whose first non-comment line is '- ' (a sequence) or a plain scalar like 'hello', or with a file that is empty/contains only comments after interpolation. Passing a fragment intended to be merged under an existing key rather than a full document also triggers it.

Common situations: A developer splits a compose file and accidentally submits a per-service snippet (a single map under a service name) as a standalone file. A YAML list of services produced by a templating tool or AI assistant. A file reduced to only comments or whitespace after variable substitution removed all content.

Related errors


AI-assisted analysis of docker/cli@4f84911bfe (2026-08-07). Data as JSON: /api/errors/4b6f3790a135aa22. Report an issue: GitHub.

Appendix: source

Thrown at cli/compose/loader/loader.go:69

	return volumespec.Parse(spec)
}

// WithDiscardEnvFiles sets the Options to discard the `env_file` section after resolving to
// the `environment` section
func WithDiscardEnvFiles(options *Options) {
	options.discardEnvFiles = true
}

// ParseYAML reads the bytes from a file, parses the bytes into a mapping
// structure, and returns it.
func ParseYAML(source []byte) (map[string]any, error) {
	var cfg any
	if err := yaml.Unmarshal(source, &cfg); err != nil {
		return nil, err
	}
	_, ok := cfg.(map[string]any)
	if !ok {
		return nil, errors.New("top-level object must be a mapping")
	}
	converted, err := convertToStringKeysRecursive(cfg, "")
	if err != nil {
		return nil, err
	}
	return converted.(map[string]any), nil
}

// Load reads a ConfigDetails and returns a fully loaded configuration
func Load(configDetails types.ConfigDetails, opt ...func(*Options)) (*types.Config, error) {
	if len(configDetails.ConfigFiles) < 1 {
		return nil, errors.New("no files specified")
	}

	options := &Options{
		Interpolate: &interp.Options{
			Substitute:      template.Substitute,
			LookupValue:     configDetails.LookupEnv,

View on GitHub (pinned to 4f84911bfe)