YAML #

YAML (YAML Ain’t Markup Language) is a data serialization format designed to be human-readable. Compared to JSON, YAML is cleaner for configuration files: no curly braces, no mandatory quotes, and comments are natively supported. This makes it the de facto standard for application configuration — Spring Boot, Docker Compose, Kubernetes, GitHub Actions, all use YAML. In Kotlin, there are two main libraries: SnakeYAML (a mature and flexible Java library) and kaml (a library integrating YAML with kotlinx.serialization for full type-safety). This article covers both in depth, including good configuration management patterns for production applications.

YAML Syntax — Quick Reference #

Before discussing libraries, it’s important to understand the YAML syntax you’ll be reading and writing:

# Comments start with #

# Scalars (single values)
name: "Budi Santoso"         # quoted string
age: 25                      # integer
height: 170.5                # float
active: true                 # boolean
empty: null                  # null (or ~)
date: 2024-08-17             # date (ISO 8601)

# Multiline strings
description: |
  First line
  Second line
  Third line  

oneLineDescription: >
  All these lines
  become one paragraph
  separated by spaces  

# Mapping (equivalent to Map/Object)
address:
  street: "Jl. Merdeka No. 1"
  city: Jakarta
  postalCode: "10110"

# Sequence (equivalent to List/Array)
languages:
  - Kotlin
  - Java
  - Python

# Mapping inside a sequence
users:
  - name: Budi
    age: 25
  - name: Sari
    age: 28

# Inline style (equivalent to JSON)
coordinate: {lat: -6.2, lng: 106.8}
tags: [kotlin, backend, api]

# Anchors and aliases (for reuse)
defaultConfig: &default
  timeout: 30
  retries: 3

production:
  <<: *default    # merge from the anchor
  host: prod.example.com

development:
  <<: *default
  host: localhost

YAML vs JSON — When to Choose Which #

AspectYAMLJSON
ReadabilityMore human-readableMore verbose
Comments✓ Supported✗ Not supported
Multiline strings✓ Native (| and >)Manual \n escapes
Data typesAuto-inferredExplicit (strings must be quoted)
Error-proneMore (indentation-sensitive)Safer (explicit structure)
Best forConfig files, CI/CDAPI responses, data exchange
ParserMore complexSimpler

SnakeYAML #

SnakeYAML is the most popular Java YAML library on the JVM. Since Kotlin runs on the JVM, it can be used directly without a special adapter.

Setup #

// build.gradle.kts
dependencies {
    implementation("org.yaml:snakeyaml:2.2")
}

Reading YAML into a Map #

import org.yaml.snakeyaml.Yaml
import java.io.File

fun main() {
    val yaml = Yaml()

    // Read from a string
    val yamlString = """
        name: Budi Santoso
        age: 25
        city: Jakarta
        languages:
          - Kotlin
          - Java
        address:
          street: Jl. Merdeka No. 1
          postalCode: "10110"
    """.trimIndent()

    val data: Map<String, Any> = yaml.load(yamlString)

    println(data["name"])           // Budi Santoso
    println(data["age"])            // 25
    @Suppress("UNCHECKED_CAST")
    val languages = data["languages"] as List<String>
    println(languages)              // [Kotlin, Java]

    @Suppress("UNCHECKED_CAST")
    val address = data["address"] as Map<String, String>
    println(address["postalCode"])  // 10110

    // Read from a file
    val fileData: Map<String, Any> = File("config.yaml").inputStream().use {
        yaml.load(it)
    }
}

Reading YAML into a Data Class #

SnakeYAML can map YAML directly to Kotlin classes, but the field names must match exactly:

import org.yaml.snakeyaml.Yaml
import org.yaml.snakeyaml.constructor.Constructor
import org.yaml.snakeyaml.LoaderOptions

// The class must have a no-arg constructor for SnakeYAML
data class DatabaseConfig(
    var host: String = "",
    var port: Int = 5432,
    var name: String = "",
    var user: String = "",
    var password: String = "",
    var maxPool: Int = 10
)

data class AppConfig(
    var server: ServerConfig = ServerConfig(),
    var database: DatabaseConfig = DatabaseConfig(),
    var logging: LoggingConfig = LoggingConfig()
)

data class ServerConfig(
    var host: String = "0.0.0.0",
    var port: Int = 8080,
    var debug: Boolean = false
)

data class LoggingConfig(
    var level: String = "INFO",
    var file: String? = null
)

fun readConfig(path: String): AppConfig {
    val options = LoaderOptions()
    val constructor = Constructor(AppConfig::class.java, options)
    val yaml = Yaml(constructor)

    return File(path).inputStream().use { yaml.load(it) }
}

The config.yaml file:

server:
  host: 0.0.0.0
  port: 8080
  debug: false

database:
  host: db.example.com
  port: 5432
  name: myapp_db
  user: admin
  password: secret123
  maxPool: 20

logging:
  level: INFO
  file: /var/log/myapp/app.log

Writing Objects to YAML #

import org.yaml.snakeyaml.DumperOptions
import org.yaml.snakeyaml.Yaml

fun writeYaml(data: Any, path: String) {
    val options = DumperOptions().apply {
        defaultFlowStyle = DumperOptions.FlowStyle.BLOCK  // block format, not inline
        isPrettyFlow = true
        indent = 2
    }

    val yaml = Yaml(options)

    File(path).bufferedWriter().use { writer ->
        yaml.dump(data, writer)
    }
}

// Write a Map to YAML
val config = mapOf(
    "server" to mapOf(
        "host" to "localhost",
        "port" to 8080
    ),
    "database" to mapOf(
        "host" to "db.local",
        "port" to 5432,
        "name" to "testdb"
    )
)

writeYaml(config, "output.yaml")

Multi-Document YAML #

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

val multiYaml = """
    ---
    name: Document 1
    version: 1.0
    ---
    name: Document 2
    version: 2.0
    ---
    name: Document 3
    version: 3.0
""".trimIndent()

val yaml = Yaml()
val documents = yaml.loadAll(multiYaml)

for (doc in documents) {
    @Suppress("UNCHECKED_CAST")
    val map = doc as Map<String, Any>
    println("${map["name"]} v${map["version"]}")
}
// Document 1 v1.0
// Document 2 v2.0
// Document 3 v3.0

kaml — YAML with kotlinx.serialization #

kaml (Kotlin YAML) is a library integrating YAML parsing with kotlinx.serialization. This gives you full type-safety with @Serializable and support for all Kotlin serialization features.

Setup #

// build.gradle.kts
plugins {
    kotlin("plugin.serialization") version "2.0.0"
}

dependencies {
    implementation("com.charleskorn.kaml:kaml:0.57.0")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.3")
}

Decode and Encode with kaml #

import com.charleskorn.kaml.Yaml
import com.charleskorn.kaml.YamlConfiguration
import kotlinx.serialization.Serializable

@Serializable
data class ServerCfg(
    val host: String,
    val port: Int,
    val debug: Boolean = false,
    val cors: List<String> = emptyList()
)

@Serializable
data class DatabaseCfg(
    val host: String,
    val port: Int = 5432,
    val name: String,
    val user: String,
    val password: String,
    val maxPool: Int = 10
)

@Serializable
data class Configuration(
    val server: ServerCfg,
    val database: DatabaseCfg,
    val debug: Boolean = false
)

fun main() {
    val yaml = Yaml(
        configuration = YamlConfiguration(
            strictMode = false  // ignore fields not in the class
        )
    )

    val yamlString = """
        server:
          host: "0.0.0.0"
          port: 8080
          debug: true
          cors:
            - "https://app.example.com"
            - "https://admin.example.com"
        database:
          host: "db.example.com"
          name: "myapp"
          user: "admin"
          password: "secret"
          maxPool: 20
    """.trimIndent()

    // Decode YAML → object
    val config = yaml.decodeFromString(Configuration.serializer(), yamlString)
    println(config.server.port)         // 8080
    println(config.database.maxPool)    // 20
    println(config.server.cors.size)    // 2

    // Encode object → YAML
    val outputYaml = yaml.encodeToString(Configuration.serializer(), config)
    println(outputYaml)
}

kaml’s Advantages vs SnakeYAML #

// kaml: type-safe, compile-time validation
@Serializable
data class User(
    val name: String,
    val age: Int,
    val email: String,
    val active: Boolean = true
)

val yaml = Yaml()
val user = yaml.decodeFromString(
    User.serializer(),
    """
    name: Budi
    age: 25
    email: [email protected]
    """.trimIndent()
)
// If 'name' is missing → a clear error at decode time
// The 'active' field uses its default value → doesn't need to be in the YAML

// SnakeYAML: not type-safe, errors only at runtime
// data classes must have a no-arg constructor
// default values don't always work as expected

Configuration Management Patterns #

These are common patterns used in production applications for managing configuration from YAML files:

Configuration with Environment Override #

import com.charleskorn.kaml.Yaml
import kotlinx.serialization.Serializable
import java.io.File

@Serializable
data class AppConfiguration(
    val name: String = "MyApp",
    val server: ServerConfiguration = ServerConfiguration(),
    val database: DatabaseConfiguration = DatabaseConfiguration(),
    val features: FeaturesConfiguration = FeaturesConfiguration()
)

@Serializable
data class ServerConfiguration(
    val host: String = "0.0.0.0",
    val port: Int = 8080,
    val timeoutSeconds: Int = 30
)

@Serializable
data class DatabaseConfiguration(
    val url: String = "jdbc:postgresql://localhost/myapp",
    val user: String = "postgres",
    val password: String = "",
    val poolMin: Int = 2,
    val poolMax: Int = 10
)

@Serializable
data class FeaturesConfiguration(
    val openRegistration: Boolean = true,
    val maxUploadMb: Int = 10,
    val maintenanceMode: Boolean = false
)

object ConfigManager {
    private lateinit var configuration: AppConfiguration

    fun load(defaultPath: String = "config.yaml"): AppConfiguration {
        val yaml = Yaml(configuration = com.charleskorn.kaml.YamlConfiguration(strictMode = false))

        // Read from the main file
        val file = File(defaultPath)
        val config = if (file.exists()) {
            file.inputStream().use {
                yaml.decodeFromStream(AppConfiguration.serializer(), it)
            }
        } else {
            println("Config file not found, using defaults")
            AppConfiguration()
        }

        // Override from environment variables
        val finalConfig = config.copy(
            database = config.database.copy(
                url = System.getenv("DATABASE_URL") ?: config.database.url,
                user = System.getenv("DATABASE_USER") ?: config.database.user,
                password = System.getenv("DATABASE_PASSWORD") ?: config.database.password
            ),
            server = config.server.copy(
                port = System.getenv("PORT")?.toIntOrNull() ?: config.server.port
            )
        )

        configuration = finalConfig
        return finalConfig
    }

    fun get(): AppConfiguration {
        check(::configuration.isInitialized) { "Configuration not loaded" }
        return configuration
    }
}

fun main() {
    val config = ConfigManager.load("config.yaml")
    println("Server running on ${config.server.host}:${config.server.port}")
    println("Database: ${config.database.url}")
}

Per-Environment Configuration #

A common pattern: one base file + override files per environment:

config/
  ├── application.yaml          ← base configuration (default values)
  ├── application-dev.yaml      ← overrides for development
  ├── application-staging.yaml  ← overrides for staging
  └── application-prod.yaml     ← overrides for production
fun loadConfigWithEnvironment(): AppConfiguration {
    val env = System.getenv("APP_ENV") ?: "dev"
    val yaml = Yaml(configuration = com.charleskorn.kaml.YamlConfiguration(strictMode = false))

    // Load the base config
    val base = File("config/application.yaml").takeIf { it.exists() }
        ?.inputStream()
        ?.use { yaml.decodeFromStream(AppConfiguration.serializer(), it) }
        ?: AppConfiguration()

    // Load the env-specific config and merge (simple: full override)
    val envFile = File("config/application-$env.yaml")
    return if (envFile.exists()) {
        envFile.inputStream().use {
            yaml.decodeFromStream(AppConfiguration.serializer(), it)
        }
    } else {
        println("No configuration for env '$env', using base")
        base
    }
}

Validating YAML Configuration #

After reading a configuration, always validate its values before use:

fun AppConfiguration.validate(): List<String> {
    val issues = mutableListOf<String>()

    if (name.isBlank()) issues.add("Application name must not be empty")
    if (server.port !in 1..65535) issues.add("Invalid server port: ${server.port}")
    if (server.timeoutSeconds <= 0) issues.add("Timeout must be positive")
    if (database.url.isBlank()) issues.add("Database URL must not be empty")
    if (database.poolMin > database.poolMax) issues.add("Pool min > pool max")
    if (features.maxUploadMb <= 0) issues.add("Max upload must be positive")

    return issues
}

fun main() {
    val config = ConfigManager.load()
    val issues = config.validate()

    if (issues.isNotEmpty()) {
        println("Invalid configuration:")
        issues.forEach { println("  • $it") }
        System.exit(1)  // stop the application
    }

    println("Configuration valid. Starting application...")
}

YAML Error Handling #

import com.charleskorn.kaml.YamlException

fun readConfigSafe(path: String): Result<AppConfiguration> {
    return runCatching {
        val yaml = Yaml(configuration = com.charleskorn.kaml.YamlConfiguration(strictMode = false))
        File(path).inputStream().use {
            yaml.decodeFromStream(AppConfiguration.serializer(), it)
        }
    }
}

fun main() {
    val result = readConfigSafe("config.yaml")

    result
        .onSuccess { config ->
            println("Configuration loaded successfully: ${config.name}")
        }
        .onFailure { e ->
            when (e) {
                is YamlException -> println("Invalid YAML: ${e.message}")
                is java.io.FileNotFoundException -> println("File not found: config.yaml")
                else -> println("Unexpected error: ${e.message}")
            }
            System.exit(1)
        }
}

Summary #

  • kaml for modern Kotlin projects — integration with kotlinx.serialization gives full type-safety, null-safety, and correctly working default values. Suitable for new projects.
  • SnakeYAML for Java interop and flexibility — more mature, can read YAML into a Map<String, Any> without defining classes first. Suitable when the YAML structure isn’t known in advance.
  • YAML is indentation-sensitive — use spaces (not tabs) for indentation, consistent within one file. Two spaces is the most common standard.
  • Environment variables for sensitive values — don’t hardcode passwords, API keys, or production URLs in YAML files that go into version control. Read them from environment variables, with default values from the YAML file as a fallback.
  • Validate configuration at startup — check all configuration values before the application starts serving requests. Better to crash with a clear error message at startup than to crash mysteriously mid-operation.
  • Separate configuration per environmentapplication.yaml for defaults, application-dev.yaml for development, application-prod.yaml for production. Don’t mix values between environments.
  • Comments are documentation — take advantage of YAML’s comment support. Document the meaning of every configuration value directly in the YAML file.
  • Multi-document YAML with --- — useful for defining several related configurations in one file, e.g., multiple service definitions or multiple test configurations.

← Previous: JSON   Next: MySQL →

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