DEV Community

Cover image for Building a Clean YAML Config Parser in Go (Without Viper)
Utkarsh Kr. Singh
Utkarsh Kr. Singh

Posted on

Building a Clean YAML Config Parser in Go (Without Viper)

Originally published on my blog:
https://utkarshkrsingh.me/blog/building-a-flexible-yaml-config-parser/

So I was working on my project Snag, and like most projects at some point, I had to deal with configuration.

Initially, I thought about using Viper since it is widely adopted in the Go ecosystem. But after looking into it, it felt like overkill for what I needed. I wasn’t dealing with multiple config sources, env overrides, or complex layering. I just needed:

  • A clean YAML config
  • Strict parsing (no silent mistakes)
  • Some flexibility in how users define commands

That last requirement turned out to be the most interesting part.


The Problem: Flexible Command Input

I wanted users to define commands in whichever way felt natural to them.

Some prefer writing commands as a single string:

run: go run main.go
Enter fullscreen mode Exit fullscreen mode

Others prefer a more explicit format:

run:
  - go
  - run
  - main.go
Enter fullscreen mode Exit fullscreen mode

Both are valid. Both are useful.

Instead of forcing one format, I decided to support both.


Struct Design (Keeping It Simple)

The config structure itself is pretty straightforward:

// Root configuration structure
type Config struct {
    Global GlobalConfig `yaml:"global"` // Global settings applied across all rules
    Watch  []Rule       `yaml:"watch"`  // List of watch rules
}

// Global-level configuration
type GlobalConfig struct {
    Debounce string   `yaml:"debounce"` // Delay before triggering actions (e.g., "500ms")
    Ignore   []string `yaml:"ignore"`   // List of directories/files to ignore
}

// Individual watch rule
type Rule struct {
    Name     string            `yaml:"name"`     // Name of the rule (used for identification/logging)
    Patterns []string          `yaml:"patterns"` // File patterns to watch (e.g., **/*.go)
    Run      Command           `yaml:"run"`      // Command to execute (custom type)
    Restart  bool              `yaml:"restart"`  // Whether to restart process on change
    Env      map[string]string `yaml:"env"`      // Environment variables for the command
}
Enter fullscreen mode Exit fullscreen mode

Nothing fancy here. The interesting part is the Run field.


Why a Custom Type for Command?

If we used a plain []string, this would fail:

run: go run main.go
Enter fullscreen mode Exit fullscreen mode

Because YAML cannot automatically convert a string into a slice.

So instead, I introduced a custom type:

// Command represents a shell command split into arguments
type Command []string
Enter fullscreen mode Exit fullscreen mode

This allows us to define exactly how YAML values should be interpreted.


Custom Unmarshalling (The Core Idea)

func (c *Command) UnmarshalYAML(value *yaml.Node) error {
    switch value.Kind {

    // Case 1: Command provided as a single string
    case yaml.ScalarNode:
        // Split the string into arguments like a shell would
        args, err := shlex.Split(value.Value)
        if err != nil {
            return err
        }

        // Assign parsed arguments to Command
        *c = args

    // Case 2: Command provided as a list
    case yaml.SequenceNode:
        var arr []string

        // Decode YAML sequence directly into []string
        if err := value.Decode(&arr); err != nil {
            return err
        }

        *c = arr

    // Any other type is invalid
    default:
        return fmt.Errorf("invalid type for run: expected string or list")
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

This is where the flexibility comes from:

  • If the YAML value is a string, we split it into arguments
  • If it is already a list, we use it directly

The key detail here is using shlex.Split instead of strings.Split.

It correctly handles things like:

run: go run "main file.go"
Enter fullscreen mode Exit fullscreen mode

Which becomes:

[]string{"go", "run", "main file.go"}
Enter fullscreen mode Exit fullscreen mode

Defaults: Small Feature, Big Impact

A config system should not force users to define every single field.

func (c *Config) ApplyDefaults() {
    // If debounce is not provided, use a sensible default
    if c.Global.Debounce == "" {
        c.Global.Debounce = "500ms"
    }
}
Enter fullscreen mode Exit fullscreen mode

This keeps things user-friendly while still giving control when needed.


Validation: Where Most Bugs Are Prevented

Parsing only tells you that the YAML is valid. It does not tell you if it is correct.

That is where validation comes in.

func (c *Config) Validate() error {

    // Ensure at least one watch rule is defined
    if len(c.Watch) == 0 {
        return errors.New("no watch rules defined")
    }

    // Validate debounce duration format
    if c.Global.Debounce != "" {
        if _, err := time.ParseDuration(c.Global.Debounce); err != nil {
            return fmt.Errorf("invalid debounce: %w", err)
        }
    }

    // Validate each rule
    for i, rule := range c.Watch {

        // Rule must have a name
        if strings.TrimSpace(rule.Name) == "" {
            return fmt.Errorf("rule at index %d is missing a name", i)
        }

        // Rule must define at least one pattern
        if len(rule.Patterns) == 0 {
            return fmt.Errorf("rule '%s' must have at least one pattern", rule.Name)
        }

        // Rule must define a command to run
        if len(rule.Run) == 0 {
            return fmt.Errorf("rule '%s' is missing a run command", rule.Name)
        }
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

This ensures:

  • Required fields are present
  • Values are valid (like duration parsing)
  • Errors are caught early with clear messages

Strict YAML Parsing (Highly Recommended)

decoder.KnownFields(true) // Reject unknown fields instead of ignoring them
Enter fullscreen mode Exit fullscreen mode

This enables strict mode.

If a user writes:

debouce: 500ms
Enter fullscreen mode Exit fullscreen mode

Instead of silently ignoring it, the parser throws an error.


Loading Flow (Putting Everything Together)

func (cfgMgr *ConfigMgr) Load() error {

    // Read config file from disk
    data, err := os.ReadFile(cfgMgr.FilePath)
    if err != nil {
        return err
    }

    // Create YAML decoder
    decoder := yaml.NewDecoder(bytes.NewReader(data))

    // Enable strict field checking
    decoder.KnownFields(true)

    // Decode YAML into struct
    if err := decoder.Decode(&cfgMgr.Config); err != nil {
        return fmt.Errorf("invalid yaml format: %w", err)
    }

    // Apply default values for missing fields
    cfgMgr.Config.ApplyDefaults()

    // Validate the final configuration
    if err := cfgMgr.Config.Validate(); err != nil {
        return fmt.Errorf("configuration validation failed: %w", err)
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

The flow is intentionally simple:

  1. Read file
  2. Decode YAML
  3. Apply defaults
  4. Validate

Example Configuration

global:
    debounce: 500ms
    ignore:
        - .git
        - vendor
        - node_modules

watch:
    - name: server
      patterns:
          - "**/*.go"
      restart: true
      run: go run main.go
      env:
          PORT: "8080"
          ENV: development
Enter fullscreen mode Exit fullscreen mode

Debugging Tip: Print the Final Config

data, _ := json.MarshalIndent(cfgMgr.Config, "", "    ")
fmt.Println(string(data))
Enter fullscreen mode Exit fullscreen mode

This helps verify that parsing, defaults, and transformations are working correctly.


What This Approach Gets Right

After building this, a few things stood out:

  • You do not always need a heavy library like Viper
  • Custom unmarshalling gives you precise control
  • Supporting flexible input formats improves usability
  • Defaults and validation make the system robust
  • Strict parsing prevents subtle configuration bugs

Final Thoughts

For tools like Snag, this approach hits a good balance between simplicity and control.

It avoids unnecessary abstraction while still being flexible and safe.

If your configuration needs are similar—single file, predictable structure, and a bit of flexibility—using yaml.v3 directly is a solid choice.

Top comments (0)