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:
| Feature | YAML | JSON |
|---|---|---|
| Basic Syntax | Indentation (spaces), minimal punctuation | Curly braces {}, brackets [], commas , |
| Comments | Supported with the # character | Not supported by default |
| Multi-line Text | Natively supported with the | or > operators | Must use the escape character \n |
| Data Reuse | Supported 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"| AMarshal — 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.v3is safe for ordinary configuration files, but don’t useyaml.Unmarshalintointerface{}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.v3is the de-facto standard YAML library in Go —go get gopkg.in/yaml.v3.- Struct tags
yaml:"name"to rename,omitemptyto skip zero values,inlineto flatten structs,flowfor one-line output.yaml.Marshalconverts Go to YAML;yaml.Unmarshalconverts YAML to Go — both work reflectively.yaml.NewDecoderfor multi-document YAML (separated by---) and streaming large files.- Custom
MarshalYAML/UnmarshalYAMLfor special types — implementMarshalYAML() (interface{}, error)andUnmarshalYAML(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.