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 #
| Aspect | YAML | JSON |
|---|---|---|
| Readability | More human-readable | More verbose |
| Comments | ✓ Supported | ✗ Not supported |
| Multiline strings | ✓ Native (| and >) | Manual \n escapes |
| Data types | Auto-inferred | Explicit (strings must be quoted) |
| Error-prone | More (indentation-sensitive) | Safer (explicit structure) |
| Best for | Config files, CI/CD | API responses, data exchange |
| Parser | More complex | Simpler |
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 environment —
application.yamlfor defaults,application-dev.yamlfor development,application-prod.yamlfor 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.