YAML #

YAML (YAML Ain’t Markup Language) is a data serialization format designed to be easy for humans to read. It dominates the configuration world — Docker Compose, Kubernetes manifests, GitHub Actions, Ansible playbooks, and many more all use YAML. In Rust, YAML support is provided by the serde_yaml crate, which works exactly like serde_json: it uses the Serialize and Deserialize traits from serde, so structs already working with JSON can be used for YAML immediately without modification. This article covers reading and writing YAML, format-control annotations, dynamic YAML Value, common config file patterns, and comparisons with JSON and TOML.

Installation #

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"

Basic Serialization and Deserialization #

Struct to YAML #

use serde::{Deserialize, Serialize};
use serde_yaml;

#[derive(Serialize, Deserialize, Debug)]
struct KonfigServer {
    host: String,
    port: u16,
    workers: u32,
    debug: bool,
    database_url: String,
}

fn main() {
    let config = KonfigServer {
        host: String::from("0.0.0.0"),
        port: 8080,
        workers: 4,
        debug: false,
        database_url: String::from("postgres://localhost/produksi"),
    };

    // Serialize to a YAML string
    let yaml_str = serde_yaml::to_string(&config).unwrap();
    println!("{}", yaml_str);
    // host: 0.0.0.0
    // port: 8080
    // workers: 4
    // debug: false
    // database_url: postgres://localhost/produksi

    // Serialize to a writer (file, stdout, etc.)
    let mut output = Vec::new();
    serde_yaml::to_writer(&mut output, &config).unwrap();
    println!("YAML bytes: {}", output.len());
}

YAML to Struct #

use serde::Deserialize;
use serde_yaml;

#[derive(Deserialize, Debug)]
struct Aplikasi {
    nama: String,
    versi: String,
    port: u16,
    fitur: Vec<String>,
    batas: Batas,
}

#[derive(Deserialize, Debug)]
struct Batas {
    maks_koneksi: u32,
    timeout_detik: u64,
    maks_body_mb: f64,
}

fn main() {
    let yaml_str = "
nama: Aplikasi Rust
versi: '2.1.0'
port: 3000
fitur:
  - auth
  - caching
  - metrics
batas:
  maks_koneksi: 100
  timeout_detik: 30
  maks_body_mb: 10.5
";

    // Deserialize from &str
    let app: Aplikasi = serde_yaml::from_str(yaml_str).unwrap();
    println!("Name: {}", app.nama);
    println!("Features: {:?}", app.fitur);
    println!("Max connections: {}", app.batas.maks_koneksi);

    // Deserialize from a file
    let file = std::fs::File::open("config.yaml");
    if let Ok(f) = file {
        let config: Aplikasi = serde_yaml::from_reader(f).unwrap();
        println!("From file: {}", config.nama);
    }

    // Informative error handling
    let yaml_rusak = "port: bukan_angka";
    match serde_yaml::from_str::<Aplikasi>(yaml_rusak) {
        Ok(_) => println!("Success"),
        Err(e) => println!("Error: {}", e),
    }
}

A Realistic Configuration File #

YAML is most often used for configuration files. Here’s a pattern commonly used in production applications:

use serde::{Deserialize, Serialize};
use std::fs;

#[derive(Debug, Deserialize, Serialize)]
struct KonfigLengkap {
    aplikasi: KonfigAplikasi,
    database: KonfigDatabase,
    redis: KonfigRedis,
    log: KonfigLog,
    keamanan: KonfigKeamanan,
}

#[derive(Debug, Deserialize, Serialize)]
struct KonfigAplikasi {
    nama: String,
    host: String,
    port: u16,
    #[serde(default)]
    debug: bool,
    #[serde(default = "versi_default")]
    versi: String,
}

fn versi_default() -> String {
    String::from("0.1.0")
}

#[derive(Debug, Deserialize, Serialize)]
struct KonfigDatabase {
    url: String,
    maks_koneksi: u32,
    #[serde(default = "timeout_default")]
    timeout_detik: u64,
    ssl: bool,
}

fn timeout_default() -> u64 { 30 }

#[derive(Debug, Deserialize, Serialize)]
struct KonfigRedis {
    url: String,
    ttl_detik: u64,
}

#[derive(Debug, Deserialize, Serialize)]
struct KonfigLog {
    level: String,
    format: String,
    file: Option<String>,
}

#[derive(Debug, Deserialize, Serialize)]
struct KonfigKeamanan {
    jwt_rahasia: String,
    bcrypt_cost: u8,
    cors_origins: Vec<String>,
}

fn muat_konfigurasi(path: &str) -> Result<KonfigLengkap, Box<dyn std::error::Error>> {
    let isi = fs::read_to_string(path)?;
    let config: KonfigLengkap = serde_yaml::from_str(&isi)?;
    Ok(config)
}

fn main() {
    // Example YAML matching the struct above
    let yaml_contoh = r#"
aplikasi:
  nama: "API Server"
  host: "0.0.0.0"
  port: 8080
  debug: true

database:
  url: "postgres://user:***@localhost:5432/db"
  maks_koneksi: 20
  ssl: true

redis:
  url: "redis://localhost:6379"
  ttl_detik: 3600

log:
  level: "info"
  format: "json"
  file: "/var/log/app.log"

keamanan:
  jwt_rahasia: "rahasia-super-panjang"
  bcrypt_cost: 12
  cors_origins:
    - "https://app.contoh.com"
    - "https://admin.contoh.com"
"#;

    let config: KonfigLengkap = serde_yaml::from_str(yaml_contoh).unwrap();
    println!("Server: {}:{}", config.aplikasi.host, config.aplikasi.port);
    println!("DB: {}", config.database.url);
    println!("CORS: {:?}", config.keamanan.cors_origins);

    // Write the config back to YAML
    let yaml_kembali = serde_yaml::to_string(&config).unwrap();
    println!("\n--- Configuration as YAML ---\n{}", yaml_kembali);
}

#[serde(...)] Annotations for YAML #

Every serde annotation that works for JSON also works for YAML — they’re part of the serde framework, not specific to any format:

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]  // Rust snake_case → YAML kebab-case
struct KonfigDocker {
    image_name: String,         // → image-name
    container_port: u16,        // → container-port
    restart_policy: String,     // → restart-policy
    environment_vars: Vec<String>, // → environment-vars
}

#[derive(Serialize, Deserialize, Debug)]
struct Layanan {
    nama: String,
    aktif: bool,

    // Use the default if absent in YAML
    #[serde(default)]
    replika: u32,

    // Not included if None
    #[serde(skip_serializing_if = "Option::is_none")]
    catatan: Option<String>,

    // Specific rename
    #[serde(rename = "healthcheck-url")]
    url_healthcheck: String,
}

fn main() {
    let layanan = Layanan {
        nama: String::from("api-server"),
        aktif: true,
        replika: 3,
        catatan: None,
        url_healthcheck: String::from("/health"),
    };

    let yaml = serde_yaml::to_string(&layanan).unwrap();
    println!("{}", yaml);
    // nama: api-server
    // aktif: true
    // replika: 3
    // healthcheck-url: /health
    // (catatan doesn't appear because it's None)

    // Deserialize with missing fields using defaults
    let yaml_minimal = "
nama: worker
aktif: false
healthcheck-url: /ping
";
    let layanan2: Layanan = serde_yaml::from_str(yaml_minimal).unwrap();
    println!("Default replica: {}", layanan2.replika); // 0
}

Dynamic YAML Value #

Like serde_json::Value, serde_yaml provides serde_yaml::Value for YAML whose structure isn’t known at compile time:

use serde_yaml::Value;

fn main() {
    let yaml_str = "
server:
  host: localhost
  port: 8080
  tags:
    - web
    - api
database:
  primary:
    url: postgres://localhost/db
    pool: 10
  replica:
    url: postgres://replica/db
    pool: 5
";

    let nilai: Value = serde_yaml::from_str(yaml_str).unwrap();

    // Access fields with indexing (same as serde_json::Value)
    println!("Host: {}", nilai["server"]["host"]);
    println!("Port: {}", nilai["server"]["port"]);

    // Iterate a sequence (array)
    if let Some(tags) = nilai["server"]["tags"].as_sequence() {
        println!("Tags:");
        for tag in tags {
            println!("  - {}", tag.as_str().unwrap_or(""));
        }
    }

    // Iterate a mapping (object)
    if let Some(db) = nilai["database"].as_mapping() {
        for (kunci, val) in db {
            println!("DB {}: pool={}", kunci.as_str().unwrap_or("?"),
                     val["pool"].as_i64().unwrap_or(0));
        }
    }

    // Build YAML from a Value programmatically
    let mut map = serde_yaml::Mapping::new();
    map.insert(Value::String("host".into()), Value::String("localhost".into()));
    map.insert(Value::String("port".into()), Value::Number(3000.into()));

    let mut seq = serde_yaml::Sequence::new();
    seq.push(Value::String("fitur-a".into()));
    seq.push(Value::String("fitur-b".into()));
    map.insert(Value::String("fitur".into()), Value::Sequence(seq));

    let nilai_baru = Value::Mapping(map);
    println!("{}", serde_yaml::to_string(&nilai_baru).unwrap());
}

YAML Features That JSON Doesn’t Have #

Multi-Document in One File #

YAML supports multiple documents in a single file, separated by ---:

use serde::Deserialize;
use serde_yaml;

#[derive(Deserialize, Debug)]
struct Sumber {
    nama: String,
    tipe: String,
}

fn main() {
    let yaml_multi = "
---
nama: database-primary
tipe: postgresql
---
nama: cache-server
tipe: redis
---
nama: message-broker
tipe: rabbitmq
";

    // Deserialize multi-document
    let iter = serde_yaml::Deserializer::from_str(yaml_multi);
    for dokumen in iter {
        let sumber = Sumber::deserialize(dokumen).unwrap();
        println!("{}: {}", sumber.nama, sumber.tipe);
    }
}

Comments in YAML #

This is an important feature that JSON doesn’t have — YAML supports comments with #. Very useful for configuration files:

# Main configuration file
# Created: 2024-08-24

server:
  host: 0.0.0.0  # Listen on all interfaces
  port: 8080     # Default port, change if there's a conflict

database:
  # Format: postgres://user:***@host:port/dbname
  url: postgres://admin:***@localhost:5432/produksi
  maks_koneksi: 20  # Adjust to the server specifications

Comments are ignored when parsing — they don’t appear when YAML is serialized back.

Anchors and Aliases — Avoiding Duplication #

YAML has an anchor (&nama) and alias (*nama) mechanism for reusing values:

# Define a base config as an anchor
default_db: &default_db
  maks_koneksi: 10
  timeout: 30
  ssl: true

database:
  primary:
    <<: *default_db  # merge key — copies all fields from the anchor
    url: postgres://primary/db
    maks_koneksi: 20  # overrides the merged field

  replica:
    <<: *default_db  # reuse the default config
    url: postgres://replica/db
use serde::Deserialize;

#[derive(Deserialize, Debug)]
struct DbConfig {
    url: String,
    maks_koneksi: u32,
    timeout: u32,
    ssl: bool,
}

#[derive(Deserialize, Debug)]
struct Database {
    primary: DbConfig,
    replica: DbConfig,
}

fn main() {
    let yaml = "
default_db: &default_db
  maks_koneksi: 10
  timeout: 30
  ssl: true

database:
  primary:
    <<: *default_db
    url: postgres://primary/db
    maks_koneksi: 20
  replica:
    <<: *default_db
    url: postgres://replica/db
";

    // serde_yaml handles anchors/aliases automatically
    let nilai: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
    println!("Primary maks_koneksi: {}", nilai["database"]["primary"]["maks_koneksi"]);
    println!("Replica maks_koneksi: {}", nilai["database"]["replica"]["maks_koneksi"]);
    println!("Primary ssl: {}", nilai["database"]["primary"]["ssl"]);
}

Embedding Config Files with include_str! #

To include a YAML file as part of the binary (embedding), use the include_str! macro:

use serde::Deserialize;

#[derive(Deserialize, Debug)]
struct KonfigDefault {
    versi: String,
    fitur_aktif: Vec<String>,
    batas_rate: u32,
}

// The config file is embedded into the binary at compile time
const KONFIGURASI_DEFAULT: &str = include_str!("../config/default.yaml");

fn main() {
    let config: KonfigDefault = serde_yaml::from_str(KONFIGURASI_DEFAULT).unwrap();
    println!("Default version: {}", config.versi);
    println!("Features: {:?}", config.fitur_aktif);

    // Common pattern: default config from the binary, override from an external file
    let config_override = std::fs::read_to_string("config.local.yaml")
        .unwrap_or_default();

    if !config_override.is_empty() {
        let local: KonfigDefault = serde_yaml::from_str(&config_override).unwrap();
        println!("Local override: {}", local.versi);
    }
}

YAML ↔ JSON Conversion #

Since both use serde, converting between YAML and JSON is very easy:

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Debug)]
struct Konfigurasi {
    nama: String,
    port: u16,
    fitur: Vec<String>,
}

fn yaml_ke_json(yaml_str: &str) -> Result<String, Box<dyn std::error::Error>> {
    // Parse YAML into a Value
    let nilai: serde_yaml::Value = serde_yaml::from_str(yaml_str)?;
    // Convert to serde_json::Value via serde
    let json_nilai: serde_json::Value = serde_json::to_value(nilai)?;
    // Serialize to a JSON string
    Ok(serde_json::to_string_pretty(&json_nilai)?)
}

fn json_ke_yaml(json_str: &str) -> Result<String, Box<dyn std::error::Error>> {
    let nilai: serde_json::Value = serde_json::from_str(json_str)?;
    let yaml_nilai: serde_yaml::Value = serde_yaml::to_value(nilai)?;
    Ok(serde_yaml::to_string(&yaml_nilai)?)
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let yaml_str = "
nama: Server Produksi
port: 443
fitur:
  - tls
  - caching
  - compression
";

    let json_hasil = yaml_ke_json(yaml_str)?;
    println!("JSON:\n{}", json_hasil);

    let json_str = r#"{"nama": "Dev Server", "port": 3000, "fitur": ["debug"]}"#;
    let yaml_hasil = json_ke_yaml(json_str)?;
    println!("YAML:\n{}", yaml_hasil);

    // Or more directly: roundtrip via a typed struct
    let config: Konfigurasi = serde_yaml::from_str(yaml_str).unwrap();
    let sebagai_json = serde_json::to_string_pretty(&config).unwrap();
    println!("Via struct:\n{}", sebagai_json);

    Ok(())
}

YAML vs JSON vs TOML — When to Choose #

AspectYAMLJSONTOML
ReadabilityVery highModerateHigh
CommentsYes (#)NoYes (#)
VerbosityLowModerateModerate
Parser complexityHighLowModerate
Multi-documentYes (---)NoNo
Anchors/aliasesYesNoNo
Error on wrong indentationYesNoNo
Best forComplex configs, DevOpsAPIs, data exchangeCargo.toml, app configs
Use YAML if:
  ✓ Config files often edited by humans (Docker, K8s, CI/CD)
  ✓ Need comments to explain values
  ✓ Deep hierarchical configuration
  ✓ Need anchors/aliases to avoid duplication

Use JSON if:
  ✓ API responses/requests — the de facto standard
  ✓ Data exchange between systems
  ✓ No comments needed
  ✓ Faster, simpler parsers

Use TOML if:
  ✓ Rust application configs (Cargo.toml, rust-toolchain.toml)
  ✓ Simple to medium config files
  ✓ Developers are more familiar with INI-like formats

Summary #

  • serde_yaml uses the same serde traits as serde_json — structs with #[derive(Serialize, Deserialize)] work immediately without modification.
  • from_str for strings, from_reader for filesfrom_reader is more efficient for large files because it streams.
  • All #[serde(...)] annotations workrename, rename_all, skip_serializing_if, default, skip are all valid for YAML just like for JSON.
  • serde_yaml::Value for dynamic YAML — access via ["kunci"], .as_sequence(), .as_mapping(), .as_str(), .as_i64().
  • Multi-document with serde_yaml::Deserializer::from_str — parse several YAML documents separated by --- in one string.
  • Anchors and aliases (&nama and *nama) are handled automatically by serde_yaml — you can use them in YAML files without extra code.
  • include_str! to embed a YAML file into the binary at compile time — suitable for default configs that must not be lost.
  • YAML ↔ JSON conversion is easy — via a typed struct (from_yamlto_json) or via an intermediate Value.
  • YAML is better than JSON for config files because it supports comments, is easier to read, and has fewer boilerplate characters (no quotes for keys, no commas).

← Previous: JSON   Next: MySQL →

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