YAML #

YAML (YAML Ain’t Markup Language) is a data serialization format that’s easier for humans to read than JSON — no curly braces, no commas, indentation defines the structure. YAML is very popular for configuration files: Kubernetes manifests, GitHub Actions, Docker Compose, Ansible playbooks — all use YAML. Go doesn’t include a YAML parser in the standard library, but gopkg.in/yaml.v3 is a mature library that has become the de-facto standard in the Go ecosystem.

Installation #

go get gopkg.in/yaml.v3

YAML Syntax — A Quick Summary #

Before getting into Go, understand the YAML syntax you’ll encounter often:

# Comments with #

# Strings — no quotes needed unless there are special characters
name: Budi Santoso
city: "Jakarta: Pusat"    # quoted because of the colon

# Numbers
age: 28
price: 15000000
pi: 3.14159

# Booleans (case-insensitive)
active: true
verified: false

# Null
email: null
note: ~        # alternative null

# Arrays (block style — one element per line)
skills:
  - Go
  - Python
  - Docker

# Arrays (flow style — one line)
tags: [backend, api, microservice]

# Nested objects
address:
  street: Jl. Merdeka No. 1
  city: Jakarta
  postal_code: "10110"     # string, not integer

# Array of objects
products:
  - id: 1
    name: Laptop
    price: 15000000
  - id: 2
    name: Mouse
    price: 350000

# Multi-line strings
description: |
  First line.
  Second line.
  Third line with a newline at the end of each line.  

summary: >
  All of these lines will be joined
  into one long line
  with spaces as separators.  

# Anchors (&) and aliases (*) — reuse values
defaults: &defaults
  timeout: 30
  retry: 3

production:
  <<: *defaults    # merge defaults
  host: prod.example.com

Quick Comparison: YAML vs JSON #

Here’s a brief comparison of the writing characteristics between YAML and JSON:

FeatureYAMLJSON
Basic SyntaxIndentation (spaces), minimal punctuationCurly braces {}, brackets [], commas ,
CommentsSupported with the # characterNot supported by default
Multi-line TextNatively supported with the | or > operatorsMust use the escape character \n
Data ReuseSupported via Anchors (&) and Aliases (*)Not supported (data must be duplicated)

The Anchor and Alias Mechanism in YAML #

The Anchor (&) mechanism defines a template data block, while the Alias (*) references and copies that template into other configuration sections to avoid redundancy:

flowchart LR
    subgraph template_block["Template Block"]
        A["&defaults (Anchor)"] -->|"Stores Values"| B["timeout: 30<br/>retry: 3"]
    end

    subgraph config_dest["Destination Configuration"]
        C["production:"] -->|"Merges"| D["<<: *defaults (Alias)"]
        C -->|"Additional Values"| E["host: prod.example.com"]
    end

    D -.->|"References Template"| A

Marshal — Go to YAML #

import "gopkg.in/yaml.v3"

type Config struct {
    Server   ServerConfig   `yaml:"server"`
    Database DatabaseConfig `yaml:"database"`
    Features []string       `yaml:"features"`
}

type ServerConfig struct {
    Host  string `yaml:"host"`
    Port  int    `yaml:"port"`
    Debug bool   `yaml:"debug"`
}

type DatabaseConfig struct {
    URL      string `yaml:"url"`
    MaxConns int    `yaml:"max_connections"`
    Timeout  int    `yaml:"timeout_seconds"`
}

cfg := Config{
    Server: ServerConfig{
        Host:  "0.0.0.0",
        Port:  8080,
        Debug: false,
    },
    Database: DatabaseConfig{
        URL:      "postgres://localhost/myapp",
        MaxConns: 25,
        Timeout:  30,
    },
    Features: []string{"auth", "payments", "notifications"},
}

data, err := yaml.Marshal(cfg)
if err != nil {
    log.Fatal(err)
}
fmt.Println(string(data))

Output:

server:
    host: 0.0.0.0
    port: 8080
    debug: false
database:
    url: postgres://localhost/myapp
    max_connections: 25
    timeout_seconds: 30
features:
    - auth
    - payments
    - notifications

Unmarshal — YAML to Go #

yamlContent := `
server:
  host: localhost
  port: 9090
  debug: true

database:
  url: postgres://localhost/testdb
  max_connections: 10
  timeout_seconds: 5

features:
  - auth
  - payments
`

var cfg Config
if err := yaml.Unmarshal([]byte(yamlContent), &cfg); err != nil {
    log.Fatal("failed to parse YAML:", err)
}

fmt.Printf("Server: %s:%d (debug=%v)\n",
    cfg.Server.Host, cfg.Server.Port, cfg.Server.Debug)
fmt.Printf("Database: %s\n", cfg.Database.URL)
fmt.Printf("Features: %v\n", cfg.Features)

Reading from a File #

func loadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("read config file: %w", err)
    }

    var cfg Config
    if err := yaml.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("parse YAML: %w", err)
    }

    return &cfg, nil
}

YAML Struct Tags #

YAML tags work similarly to JSON but with the yaml:"..." syntax:

type AppConfig struct {
    // Rename the field
    AppName string `yaml:"app_name"`

    // omitempty: skip if it's the zero value
    DebugMode bool   `yaml:"debug,omitempty"`
    LogLevel  string `yaml:"log_level,omitempty"`

    // "-": always skip
    InternalSecret string `yaml:"-"`

    // inline: "flatten" fields from another struct into this level
    CommonConfig `yaml:",inline"`

    // flow: use flow style (one line) when marshaling
    Tags []string `yaml:"tags,flow"`
}

type CommonConfig struct {
    Timeout int `yaml:"timeout"`
    Retry   int `yaml:"retry"`
}

// Example with inline:
// timeout: 30     ← from CommonConfig (inlined, not nested)
// retry: 3
// tags: [a, b, c] ← flow style

Custom Marshalers and Unmarshalers #

Implement the yaml.Marshaler and yaml.Unmarshaler interfaces for full control:

// A Duration serializable to the "30s", "5m", "1h" format
type Duration struct {
    time.Duration
}

func (d Duration) MarshalYAML() (interface{}, error) {
    return d.String(), nil  // "1m30s", "5s", etc.
}

func (d *Duration) UnmarshalYAML(value *yaml.Node) error {
    var s string
    if err := value.Decode(&s); err != nil {
        return err
    }
    dur, err := time.ParseDuration(s)
    if err != nil {
        return fmt.Errorf("invalid duration format %q: %w", s, err)
    }
    d.Duration = dur
    return nil
}

// Usage
type ServerConfig struct {
    Host            string   `yaml:"host"`
    ReadTimeout     Duration `yaml:"read_timeout"`
    WriteTimeout    Duration `yaml:"write_timeout"`
    ShutdownTimeout Duration `yaml:"shutdown_timeout"`
}

// YAML input
yamlStr := `
host: localhost
read_timeout: 5s
write_timeout: 10s
shutdown_timeout: 30s
`

var srv ServerConfig
yaml.Unmarshal([]byte(yamlStr), &srv)
fmt.Println(srv.ReadTimeout.Duration)  // 5s as a time.Duration

Multi-Document YAML #

YAML supports multiple documents in one file, separated by ---:

// Kubernetes-style multi-document
multiDoc := `
---
kind: Service
name: api-server
port: 8080
---
kind: Database
name: postgres
port: 5432
---
kind: Cache
name: redis
port: 6379
`

decoder := yaml.NewDecoder(strings.NewReader(multiDoc))

for {
    var doc map[string]interface{}
    if err := decoder.Decode(&doc); err != nil {
        if err == io.EOF {
            break
        }
        log.Fatal(err)
    }
    fmt.Printf("Kind: %s, Name: %s\n", doc["kind"], doc["name"])
}

YAML vs JSON — When to Use Which #

USE YAML for:
  ✓ Human-edited configuration files (config.yaml, docker-compose.yml)
  ✓ Infrastructure as Code (Kubernetes, Ansible, Terraform)
  ✓ CI/CD pipeline definitions (GitHub Actions, GitLab CI)
  ✓ API documentation (OpenAPI/Swagger)
  ✓ Templates with comments and anchor/alias

USE JSON for:
  ✓ API requests/responses (JSON is more universal)
  ✓ Data storage (databases, caches)
  ✓ Service-to-service communication (JSON parses faster)
  ✓ package.json, go.mod-style configs (no comments needed)
  ✓ When the structure is very deep — YAML indentation can be confusing

Feature comparison:
  YAML                    JSON
  Comments (#)    ✓       None
  Anchor/alias    ✓       None
  Multi-line str  ✓       Limited
  Type coercion   ✓       None
  Readability     ✓       More verbose
  Parsing speed   Slower  Faster
  Security        Riskier Safer
YAML can be dangerous when parsing files from untrusted sources. Old YAML specs supported executable types (!!python/object) and other dangerous features. gopkg.in/yaml.v3 is safe for ordinary configuration files, but don’t use yaml.Unmarshal into interface{} for user input without validation.

Complete Example Program — Config Loader #

The following program builds a type-safe config loader with validation, default values, and environment variable support:

package main

import (
    "fmt"
    "log"
    "os"
    "strings"
    "time"

    "gopkg.in/yaml.v3"
)

// ── Config Types ──────────────────────────────────────────────

type Duration struct{ time.Duration }

func (d Duration) MarshalYAML() (interface{}, error) { return d.String(), nil }

func (d *Duration) UnmarshalYAML(v *yaml.Node) error {
    var s string
    if err := v.Decode(&s); err != nil {
        return err
    }
    dur, err := time.ParseDuration(s)
    if err != nil {
        return fmt.Errorf("invalid duration %q: %w", s, err)
    }
    d.Duration = dur
    return nil
}

type AppConfig struct {
    App      AppSection      `yaml:"app"`
    HTTP     HTTPSection     `yaml:"http"`
    Database DatabaseSection `yaml:"database"`
    Redis    RedisSection    `yaml:"redis"`
    Log      LogSection      `yaml:"log"`
}

type AppSection struct {
    Name    string `yaml:"name"`
    Version string `yaml:"version"`
    Env     string `yaml:"env"`     // development, staging, production
}

type HTTPSection struct {
    Host            string     `yaml:"host"`
    Port            int        `yaml:"port"`
    ReadTimeout     Duration   `yaml:"read_timeout"`
    WriteTimeout    Duration   `yaml:"write_timeout"`
    ShutdownTimeout Duration   `yaml:"shutdown_timeout"`
    CORS            CORSConfig `yaml:"cors"`
}

type CORSConfig struct {
    Enabled bool     `yaml:"enabled"`
    Origins []string `yaml:"origins"`
}

type DatabaseSection struct {
    Host         string   `yaml:"host"`
    Port         int      `yaml:"port"`
    Name         string   `yaml:"name"`
    User         string   `yaml:"user"`
    Password     string   `yaml:"password"`
    MaxOpenConns int      `yaml:"max_open_conns"`
    MaxIdleConns int      `yaml:"max_idle_conns"`
    ConnLifetime Duration `yaml:"conn_lifetime"`
    SSLMode      string   `yaml:"ssl_mode"`
}

func (d DatabaseSection) DSN() string {
    return fmt.Sprintf("host=%s port=%d dbname=%s user=%s password=%s sslmode=%s",
        d.Host, d.Port, d.Name, d.User, d.Password, d.SSLMode)
}

type RedisSection struct {
    Host     string   `yaml:"host"`
    Port     int      `yaml:"port"`
    Password string   `yaml:"password"`
    DB       int      `yaml:"db"`
    Timeout  Duration `yaml:"timeout"`
}

func (r RedisSection) Addr() string {
    return fmt.Sprintf("%s:%d", r.Host, r.Port)
}

type LogSection struct {
    Level  string `yaml:"level"`   // debug, info, warn, error
    Format string `yaml:"format"`  // json, text
    Output string `yaml:"output"`  // stdout, stderr, file path
}

// ── Default Config ────────────────────────────────────────────

var defaultConfig = AppConfig{
    App: AppSection{
        Name:    "myapp",
        Version: "0.0.1",
        Env:     "development",
    },
    HTTP: HTTPSection{
        Host:            "0.0.0.0",
        Port:            8080,
        ReadTimeout:     Duration{5 * time.Second},
        WriteTimeout:    Duration{10 * time.Second},
        ShutdownTimeout: Duration{30 * time.Second},
        CORS: CORSConfig{
            Enabled: true,
            Origins: []string{"*"},
        },
    },
    Database: DatabaseSection{
        Host:         "localhost",
        Port:         5432,
        MaxOpenConns: 25,
        MaxIdleConns: 5,
        ConnLifetime: Duration{30 * time.Minute},
        SSLMode:      "disable",
    },
    Redis: RedisSection{
        Host:    "localhost",
        Port:    6379,
        DB:      0,
        Timeout: Duration{3 * time.Second},
    },
    Log: LogSection{
        Level:  "info",
        Format: "json",
        Output: "stdout",
    },
}

// ── Loader ────────────────────────────────────────────────────

func LoadConfig(path string) (*AppConfig, error) {
    cfg := defaultConfig  // start from the defaults

    // Load from a file if it exists
    if path != "" {
        data, err := os.ReadFile(path)
        if err != nil {
            if !os.IsNotExist(err) {
                return nil, fmt.Errorf("read config file: %w", err)
            }
            log.Printf("Config file %q not found, using defaults", path)
        } else {
            if err := yaml.Unmarshal(data, &cfg); err != nil {
                return nil, fmt.Errorf("parse config YAML: %w", err)
            }
        }
    }

    // Override from environment variables
    overrideFromEnv(&cfg)

    // Validate
    if err := validateConfig(&cfg); err != nil {
        return nil, fmt.Errorf("invalid config: %w", err)
    }

    return &cfg, nil
}

func overrideFromEnv(cfg *AppConfig) {
    envMap := map[string]*string{
        "APP_ENV":        &cfg.App.Env,
        "DB_HOST":        &cfg.Database.Host,
        "DB_NAME":        &cfg.Database.Name,
        "DB_USER":        &cfg.Database.User,
        "DB_PASSWORD":    &cfg.Database.Password,
        "REDIS_HOST":     &cfg.Redis.Host,
        "REDIS_PASSWORD": &cfg.Redis.Password,
    }
    for envKey, target := range envMap {
        if val := os.Getenv(envKey); val != "" {
            *target = val
        }
    }
}

func validateConfig(cfg *AppConfig) error {
    var errs []string

    if cfg.App.Name == "" {
        errs = append(errs, "app.name is required")
    }
    if cfg.HTTP.Port <= 0 || cfg.HTTP.Port > 65535 {
        errs = append(errs, fmt.Sprintf("http.port %d is invalid", cfg.HTTP.Port))
    }
    if cfg.Database.Host == "" {
        errs = append(errs, "database.host is required")
    }
    validLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true}
    if !validLevels[cfg.Log.Level] {
        errs = append(errs, fmt.Sprintf("log.level %q is invalid", cfg.Log.Level))
    }

    if len(errs) > 0 {
        return fmt.Errorf(strings.Join(errs, "; "))
    }
    return nil
}

// ── Main ──────────────────────────────────────────────────────

func main() {
    // Example YAML config
    exampleYAML := `
app:
  name: online-store
  version: "1.2.0"
  env: production

http:
  host: "0.0.0.0"
  port: 443
  read_timeout: 5s
  write_timeout: 15s
  shutdown_timeout: 30s
  cors:
    enabled: true
    origins:
      - https://onlinestore.id
      - https://admin.onlinestore.id

database:
  host: db.internal
  port: 5432
  name: store_production
  user: app_user
  password: secret123
  max_open_conns: 50
  max_idle_conns: 10
  conn_lifetime: 1h
  ssl_mode: require

redis:
  host: redis.internal
  port: 6379
  password: redis_secret
  db: 0
  timeout: 2s

log:
  level: info
  format: json
  output: stdout
`

    // Write to a temp file for the demo
    tmpFile, _ := os.CreateTemp("", "config-*.yaml")
    tmpFile.WriteString(exampleYAML)
    tmpFile.Close()
    defer os.Remove(tmpFile.Name())

    cfg, err := LoadConfig(tmpFile.Name())
    if err != nil {
        log.Fatal("Failed to load config:", err)
    }

    fmt.Println("=== Configuration Loaded Successfully ===")
    fmt.Printf("App         : %s v%s (%s)\n", cfg.App.Name, cfg.App.Version, cfg.App.Env)
    fmt.Printf("HTTP        : %s:%d\n", cfg.HTTP.Host, cfg.HTTP.Port)
    fmt.Printf("Read Timeout: %v\n", cfg.HTTP.ReadTimeout.Duration)
    fmt.Printf("CORS Origins: %v\n", cfg.HTTP.CORS.Origins)
    fmt.Printf("Database DSN: %s\n", cfg.Database.DSN())
    fmt.Printf("Redis Addr  : %s\n", cfg.Redis.Addr())
    fmt.Printf("Log Level   : %s (%s)\n", cfg.Log.Level, cfg.Log.Format)

    // Marshal back to YAML (for verification or export)
    fmt.Println("\n=== Marshaling Back to YAML ===")
    output, _ := yaml.Marshal(cfg)
    fmt.Println(string(output))
}

Summary #

  • gopkg.in/yaml.v3 is the de-facto standard YAML library in Go — go get gopkg.in/yaml.v3.
  • Struct tags yaml:"name" to rename, omitempty to skip zero values, inline to flatten structs, flow for one-line output.
  • yaml.Marshal converts Go to YAML; yaml.Unmarshal converts YAML to Go — both work reflectively.
  • yaml.NewDecoder for multi-document YAML (separated by ---) and streaming large files.
  • Custom MarshalYAML/UnmarshalYAML for special types — implement MarshalYAML() (interface{}, error) and UnmarshalYAML(v *yaml.Node) error.
  • Always start from default config and override with a file, then environment variables — this is a robust pattern for production applications.
  • Validate the config after loading — don’t let invalid configuration reach runtime.
  • Environment variables for secrets (passwords, API keys) — don’t write secrets into YAML files committed to git.
  • YAML for configuration, JSON for APIs — this is the most common and sensible division of labor.
  • Anchors (&) and aliases (*) in YAML allow value reuse — useful for multi-environment configs without duplication.

← Previous: JSON   Next: MySQL →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact