YAML & TOML

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Apache Groovy 6.0 (6.0.0, 2026-09-22) and every example was run with Groovy 6.0.0 on JDK 26 (checked on 2026-10-14). YAML and TOML are the two configuration formats people write by hand, and Groovy reads them with the same slurper idea as JSON: YamlSlurper and TomlSlurper return maps, lists and scalars, and YamlBuilder and TomlBuilder write them. Both modules sit on top of Jackson, which explains most of their behaviour, good and bad. The JSON counterparts, and the JsonDateHandling type reused here, are on JSON; the collection methods used to navigate the results are on Working with Collections, and the builder syntax on Builders.

The modules

Each format is its own artifact, groovy-yaml and groovy-toml. Both ship in the Groovy distribution and are built on Jackson: the distribution’s lib directory holds jackson-databind, the YAML and TOML data-format modules and jackson-datatype-jsr310 for java.time. A project declares the dependency; a script that runs outside the distribution can use @Grab.

@Grab('org.apache.groovy:groovy-yaml:6.0.0')
@Grab('org.apache.groovy:groovy-toml:6.0.0')
import groovy.yaml.YamlSlurper
import groovy.toml.TomlSlurper
dependencies {
    implementation("org.apache.groovy:groovy-yaml:6.0.0")
    implementation("org.apache.groovy:groovy-toml:6.0.0")
}
<dependency>
    <groupId>org.apache.groovy</groupId>
    <artifactId>groovy-yaml</artifactId>
    <version>6.0.0</version>
</dependency>
<dependency>
    <groupId>org.apache.groovy</groupId>
    <artifactId>groovy-toml</artifactId>
    <version>6.0.0</version>
</dependency>

TOML support first appeared in Groovy 4.0 as an incubating module and is stable in 6.0. The YAML module has been available since 3.0. The two files below are the data of the greenhouse scenario used throughout this page.

# Zones of the greenhouse and their watering rules
greenhouse: Backyard East
commissioned: 2026-03-01
defaults: &watering
  waterEveryHours: 6
  minMoisture: 0.35
zones:
  - name: north-bench
    kind: Bench
    plants: [tomatoes, basil]
    rules: *watering
  - name: hanging-baskets
    kind: HangingBasket
    plants: [strawberries]
    rules:
      <<: *watering
      waterEveryHours: 3
  - name: bed-1
    kind: Bed
    plants: []
    notes: |
      Needs new soil.
      Check drainage first.
# Greenhouse configuration
name = "Backyard East"
commissioned = 2026-03-01
sensors = ["bench-1", "bench-2", "basket-1"]

[watering]
everyHours = 6
minMoisture = 0.35
quietFrom = 22:00:00
quietUntil = 2026-03-01T06:30:00

[server]
host = "localhost"
port = 8080
debug = false
updated = 2026-03-01T08:30:00-08:00

[[zones]]
name = "north-bench"
plants = ["tomatoes", "basil"]

[[zones]]
name = "hanging-baskets"
plants = ["strawberries"]

YAML

Reading YAML with YamlSlurper

YamlSlurper has parseText for a String and parse for a File, a Path, a Reader and an InputStream. The result is a map, list or scalar, just as with JsonSlurper, so the property syntax and the spread operator work directly.

import groovy.yaml.YamlSlurper

def layout = new YamlSlurper().parse(new File('config/zones.yaml'))

println layout.getClass().simpleName
println layout.greenhouse
println layout.zones*.name
println layout.zones.find { it.kind == 'HangingBasket' }.plants
println layout.zones.plants.flatten()
println layout.zones.find { it.name == 'bed-1' }.notes
println layout.zones[0].notes            // missing key
println layout.zones.collect { it.plants.size() }
LazyMap
Backyard East
[north-bench, hanging-baskets, bed-1]
[strawberries]
[tomatoes, basil, strawberries]
Needs new soil.
Check drainage first.

null
[2, 1, 0]

A key that is absent gives null, and a block scalar written with | keeps its line breaks, trailing newline included. The results use the same LazyMap class as JsonSlurper, which is a java.util.Map but not a LinkedHashMap.

What a YAML scalar becomes

The slurper does not read YAML itself: it converts the document to JSON-like data first, and Jackson decides the type of every unquoted scalar by the YAML 1.1 rules. Those rules surprise people.

import groovy.yaml.YamlSlurper

def doc = new YamlSlurper().parseText('''\
count: 3
ratio: 0.35
version: 1.10
flag: true
nothing: ~
empty:
hex: 0x1F
zip: 02134
quoted: '02134'
country: NO
answer: Yes
quotedNo: 'no'
''')

doc.each { key, value ->
    println "${key.padRight(9)}${value?.getClass()?.simpleName.toString().padRight(11)}$value"
}
count    Integer    3
ratio    BigDecimal 0.35
version  BigDecimal 1.1
flag     Boolean    true
nothing  null       null
empty    null       null
hex      Integer    31
zip      Integer    1116
quoted   String     02134
country  Boolean    false
answer   Boolean    true
quotedNo String     no
  • version: 1.10 is the number 1.1; quote it ('1.10') when it is a version.

  • zip: 02134 is read as octal and gives 1116, and 0x1F as hexadecimal; quote anything that is an identifier.

  • country: NO is the boolean false, the well-known "Norway problem", and Yes is true. A quoted 'no' is a String. Quote country codes and any other value that happens to be a YAML boolean word.

  • A key with no value and ~ are both null.

A document with several parts separated by --- comes back as a list with one element per document, an empty text as an empty list, and a syntax error as a Jackson exception.

import groovy.yaml.YamlSlurper

println new YamlSlurper().parseText('just text')
println new YamlSlurper().parseText('- 1\n- two\n- 3.5')
println new YamlSlurper().parseText('first: 1\n---\nsecond: 2\n')    // two documents: a list
println new YamlSlurper().parseText('')                              // no document: an empty list

try {
    new YamlSlurper().parseText('plants: [tomatoes, basil\nzones: 2')
} catch (e) {
    println e.getClass().simpleName
    println e.message.readLines().first()
}
just text
[1, two, 3.5]
[[first:1], [second:2]]
[]
MarkedYAMLException
while parsing a flow sequence

The document list is the one case where a YAML result is a List although the file looks like a single mapping, so code that expects a map should check for it when it reads files that may be concatenated.

Dates in YAML

In the untyped API, date and time values arrive as String, because the data pass through a JSON-like representation. The slurpers have a dateHandling property (type groovy.json.JsonDateHandling, default STRING), and in Groovy 6.0 its non-default values convert only a value that carries an offset. A date alone and a local date-time stay strings.

import groovy.json.JsonDateHandling
import groovy.yaml.YamlSlurper

def text = '''\
commissioned: 2026-03-01
updated: 2026-03-01T08:30:00-08:00
local: 2026-03-01T08:30:00
'''

JsonDateHandling.values().each { handling ->
    def doc = new YamlSlurper().setDateHandling(handling).parseText(text)
    def kinds = doc.keySet().sort().collect { key -> "$key=${doc[key].getClass().simpleName}" }
    println "${handling.name().padRight(17)}$kinds"
}
STRING           [commissioned=String, local=String, updated=String]
UTIL_DATE        [commissioned=String, local=String, updated=Date]
INSTANT          [commissioned=String, local=String, updated=Instant]
OFFSET_DATE_TIME [commissioned=String, local=String, updated=OffsetDateTime]

For real java.time values, use the typed API in the next section.

Typed parsing

parseTextAs(Class, String) and parseAs(Class, source) bind the document directly to a class through Jackson data binding. The source of parseAs can be a Reader, InputStream, File or Path. Properties, nested objects, lists and java.time types are converted, with the generic element types respected, unlike as coercion of a JSON result.

import groovy.yaml.YamlSlurper
import java.time.LocalDate

class Rules { int waterEveryHours; BigDecimal minMoisture }
class Zone { String name; String kind; List<String> plants; Rules rules }
class Layout { String greenhouse; LocalDate commissioned; List<Zone> zones }

def layout = new YamlSlurper().parseTextAs(Layout, '''\
greenhouse: Backyard East
commissioned: 2026-03-01
zones:
  - name: north-bench
    kind: Bench
    plants: [tomatoes, basil]
    rules: { waterEveryHours: 6, minMoisture: 0.35 }
  - name: bed-1
    kind: Bed
    plants: []
''')

println layout.commissioned.getClass().simpleName
println layout.commissioned.plusDays(30)
println layout.zones[0].getClass().simpleName
println layout.zones[0].rules.minMoisture
println layout.zones[1].rules

// from a file; parseAs accepts a File, Path, Reader or InputStream
println new YamlSlurper().parseAs(Layout, new ByteArrayInputStream('greenhouse: X'.bytes)).greenhouse

try {
    new YamlSlurper().parseTextAs(Layout, 'greenhouse: X\nunknown: 1')
} catch (e) {
    println e.getClass().simpleName
    println e.message.readLines().first()
}
LocalDate
2026-03-31
Zone
0.35
null
X
UnrecognizedPropertyException
Unrecognized field "unknown" (class Layout), not marked as ignorable (4 known properties: "commissioned", "metaClass", "greenhouse", "zones")

The binding is strict: an unknown key is an UnrecognizedPropertyException, not a silently default object. Standard Jackson annotations (@JsonProperty, @JsonFormat, @JsonIgnore, and so on) are honoured, which is the way to map names that are not valid identifiers or to control a date pattern.

import com.fasterxml.jackson.annotation.JsonFormat
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonProperty
import groovy.yaml.YamlBuilder
import groovy.yaml.YamlSlurper

class Sensor {
    @JsonProperty('sensor-id') String id
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = 'yyyy-MM-dd') Date installed
    @JsonIgnore String location
}

def sensor = new YamlSlurper().parseTextAs(Sensor, 'sensor-id: bench-1\ninstalled: 2026-03-01\n')
println sensor.id
println sensor.installed.toInstant()
println YamlBuilder.toYaml(new Sensor(id: 'bench-2', installed: sensor.installed, location: 'north'))
bench-1
2026-03-01T00:00:00Z
installed: "2026-03-01"
sensor-id: "bench-2"

Groovy’s as also works on the untyped result for a simple class (parseText(text) as Config), but it carries the quiet failure modes described on JSON; prefer parseTextAs.

Anchors and aliases

YAML lets a node be named with &name (an anchor) and reused with *name (an alias), and << merges a mapping into another. YamlSlurper does not resolve them. The alias arrives as the anchor’s name, and the merge key stays a literal entry. This is a limitation of the Jackson YAML back end that its maintainers have declined to change, so the slurper cannot be told to resolve aliases.

import groovy.yaml.YamlSlurper

def loaded = new YamlSlurper().parseText('''\
defaults: &watering
  waterEveryHours: 6
  minMoisture: 0.35
north: *watering
south:
  <<: *watering
  waterEveryHours: 3
''')

println loaded.defaults
println loaded.north          // the anchor's name, not the mapping
println loaded.south          // the merge key is a literal entry
[waterEveryHours:6, minMoisture:0.35]
watering
[<<:watering, waterEveryHours:3]

Typed binding hits the same wall, and reports it as a conversion error because a string cannot be turned into the target class.

import groovy.yaml.YamlSlurper

class Rules { int waterEveryHours; BigDecimal minMoisture }
class Zone { String name; Rules rules }
class Layout { Rules defaults; List<Zone> zones }

def yaml = '''\
defaults: &watering
  waterEveryHours: 6
  minMoisture: 0.35
zones:
  - name: north-bench
    rules: *watering
'''

try {
    new YamlSlurper().parseTextAs(Layout, yaml)
} catch (e) {
    println e.getClass().simpleName
    println e.message.readLines().first()
}
MismatchedInputException
Cannot construct instance of `Rules` (although at least one Creator exists): no String-argument constructor/factory method to deserialize from String value ('watering')

There are three ways forward. The simplest is to avoid anchors in files the program reads with Groovy, repeating the values or moving the defaults into code. A mutable, setter-based class can opt in to Jackson’s object identity with @JsonIdentityInfo, and an anchored mapping then binds to one shared instance.

import com.fasterxml.jackson.annotation.JsonIdentityInfo
import com.fasterxml.jackson.annotation.ObjectIdGenerators
import groovy.yaml.YamlSlurper

@JsonIdentityInfo(generator = ObjectIdGenerators.StringIdGenerator, property = '@id')
class Rules { int waterEveryHours; BigDecimal minMoisture }
class Zone { String name; Rules rules }
class Layout { Rules defaults; List<Zone> zones }

def layout = new YamlSlurper().parseTextAs(Layout, '''\
defaults: &watering
  waterEveryHours: 6
  minMoisture: 0.35
zones:
  - name: north-bench
    rules: *watering
  - name: bed-1
    rules: *watering
''')

println layout.zones*.rules*.waterEveryHours
println layout.zones[0].rules.is(layout.defaults)
println layout.zones[0].rules.is(layout.zones[1].rules)
[6, 6]
true
true

This works only for classes bound through setters; a type built by a constructor or an immutable class fails even for a plain backward reference, so do not combine it with @Immutable targets. The third way is to use SnakeYAML directly, which resolves anchors, aliases and merge keys in full. SafeConstructor limits the result to maps, lists and scalars and refuses the tagged instantiation that makes careless YAML loading dangerous.

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

def yaml = new Yaml(new SafeConstructor(new LoaderOptions()))
def layout = yaml.load(new File('config/zones.yaml').text)

println layout.zones*.rules             // aliases resolved, merge key applied
println layout.defaults.is(layout.zones[0].rules)
println layout.commissioned.getClass().name   // YAML 1.1 timestamp
[[waterEveryHours:6, minMoisture:0.35], [waterEveryHours:3, minMoisture:0.35], null]
true
java.util.Date

SnakeYAML is on the classpath here only because Jackson depends on it, so a project that uses it directly declares org.yaml:snakeyaml itself. It follows YAML 1.1 typing, so the untagged timestamp became a java.util.Date, where YamlSlurper returns a String. It also caps the input size and the number of aliases by default, which is what protects a program from an alias-expansion document; raise those caps through LoaderOptions only for input you trust.

Writing YAML with YamlBuilder

YamlBuilder uses the same builder syntax as JsonBuilder and prints the result with toString(). A builder instance starts the output with the --- document marker; the static toYaml does not, and it also serializes typed objects through Jackson.

import groovy.yaml.YamlBuilder

def beds = [[name: 'north-bench', plants: ['tomatoes', 'basil']], [name: 'bed-1', plants: []]]

def yaml = new YamlBuilder()
yaml.greenhouse {
    name 'Backyard East'
    open true
    ratio 0.35
    note "line one\nline two"
    flag 'yes'
    zones beds
    limits {
        maxTemperature 35
    }
}
print yaml
println '---- toYaml'
print YamlBuilder.toYaml([a: 1, list: [1, 2], nested: [b: null]])
println '---- list'
def list = new YamlBuilder()
list(beds) { bed -> name bed.name; count bed.plants.size() }
print list
---
greenhouse:
  name: "Backyard East"
  open: true
  ratio: 0.35
  note: "line one\nline two"
  flag: "yes"
  zones:
  - name: "north-bench"
    plants:
    - "tomatoes"
    - "basil"
  - name: "bed-1"
    plants: []
  limits:
    maxTemperature: 35
---- toYaml
a: 1
list:
- 1
- 2
nested:
  b: null
---- list
---
- name: "north-bench"
  count: 2
- name: "bed-1"
  count: 0

Strings are always double-quoted in the output, which keeps values such as yes or 02134 safe to read back. The builder has the same naming trap as the JSON builders: a local variable named like a node is called instead of creating the node, so name the variable beds and the node zones, not the same word for both (see JSON). toYaml handles java.time values, which are written as quoted strings and read back by parseAs with their types intact, including a non-UTC offset.

TOML

Reading TOML with TomlSlurper

TomlSlurper mirrors YamlSlurper: parseText, parse for a File, Path, Reader or InputStream, and the typed parseTextAs and parseAs. Tables become maps, arrays of tables ([[zones]]) become lists of maps, and the usual GPath navigation applies.

import groovy.toml.TomlSlurper

def config = new TomlSlurper().parse(new File('config/greenhouse.toml'))

println config.name
println config.sensors
println config.watering
println config.zones.name
println config.zones.plants.flatten()
println config.server.port.getClass().simpleName
println config.watering.minMoisture.getClass().simpleName
println config.commissioned.getClass().simpleName     // a TOML date, untyped
println config.server.updated
Backyard East
[bench-1, bench-2, basket-1]
[everyHours:6, minMoisture:0.35, quietFrom:22:00:00, quietUntil:2026-03-01T06:30:00]
[north-bench, hanging-baskets]
[tomatoes, basil, strawberries]
Integer
BigDecimal
String
2026-03-01T08:30:00-08:00

TOML is typed in its own syntax, so none of the YAML guessing applies: a string must be quoted, 22:00:00 is a time, and an integer is an Integer (or Long when large). TOML has no null at all. The other value shapes come back like this.

import groovy.toml.TomlSlurper

def doc = new TomlSlurper().parseText("""\
hex = 0xFF
big = 12345678901
ratio = 1.5
inline = { x = 1, y = "z" }
literal = 'C:\\raw\\path'
multi = \"\"\"
line1
line2\"\"\"
""")

doc.each { key, value -> println "${key.padRight(8)}${value.getClass().simpleName.padRight(12)}$value" }

try {
    new TomlSlurper().parseText('a = \nb = 2')
} catch (e) {
    println e.getClass().simpleName
    println e.message.readLines().first()
}
try {
    new TomlSlurper().parseText('a = 1\na = 2')
} catch (e) {
    println e.message.readLines().first()
}
println new TomlSlurper().parseText('')
hex     Integer     255
big     Long        12345678901
ratio   BigDecimal  1.5
inline  LazyMap     [x:1, y:z]
literal String      C:\raw\path
multi   String      line1
line2
TomlStreamReadException
Newline not permitted here
Duplicate key
[:]

Hexadecimal literals, inline tables, multi-line strings and literal (single-quoted) strings all work. A key assigned twice and a missing value are errors, reported with Jackson’s TomlStreamReadException, and an empty document is an empty map.

Dates in TOML

TOML has native date, time and date-time types, but the untyped API still returns them as String (the underlying Jackson module gives java.time objects only when a target type is known). The dateHandling property behaves exactly as for YAML: only a value with an offset can be converted.

import groovy.json.JsonDateHandling
import groovy.toml.TomlSlurper

def text = '''\
commissioned = 2026-03-01
quietFrom = 22:00:00
local = 2026-03-01T08:30:00
updated = 2026-03-01T08:30:00-08:00
'''

JsonDateHandling.values().each { handling ->
    def doc = new TomlSlurper().setDateHandling(handling).parseText(text)
    def kinds = doc.keySet().sort().collect { key -> "$key=${doc[key].getClass().simpleName}" }
    println "${handling.name().padRight(17)}$kinds"
}
STRING           [commissioned=String, local=String, quietFrom=String, updated=String]
UTIL_DATE        [commissioned=String, local=String, quietFrom=String, updated=Date]
INSTANT          [commissioned=String, local=String, quietFrom=String, updated=Instant]
OFFSET_DATE_TIME [commissioned=String, local=String, quietFrom=String, updated=OffsetDateTime]

Typed parsing

With a target class, the local date, local time, local date-time and offset date-time map to LocalDate, LocalTime, LocalDateTime and OffsetDateTime, and the offset survives. Arrays of tables bind to a List of the element type.

import groovy.toml.TomlSlurper
import java.time.*

class Server { String host; int port; boolean debug; OffsetDateTime updated }
class Watering { int everyHours; BigDecimal minMoisture; LocalTime quietFrom; LocalDateTime quietUntil }
class Zone { String name; List<String> plants }
class Config { String name; LocalDate commissioned; List<String> sensors; Watering watering; Server server; List<Zone> zones }

def config = new TomlSlurper().parseAs(Config, new File('config/greenhouse.toml'))

println config.name
println config.commissioned
println config.watering.quietFrom
println config.watering.quietUntil
println config.server.updated
println config.server.updated.offset
println config.zones*.getClass()*.simpleName
println config.zones*.plants

// simple classes can also be built with as
println((new TomlSlurper().parseText('host = "localhost"\nport = 8080') as Server).port)
Backyard East
2026-03-01
22:00
2026-03-01T06:30
2026-03-01T08:30-08:00
-08:00
[Zone, Zone]
[[tomatoes, basil], [strawberries]]
8080

Because the slurper uses Jackson, the same annotations apply as for YAML, and an unknown key is an error in the same way.

Writing TOML with TomlBuilder

TomlBuilder has the same builder syntax and writes dotted keys for nested objects, and inline tables for a list of objects. TomlBuilder.toToml serializes a typed object.

import groovy.toml.TomlBuilder

def beds = [[name: 'north-bench', plants: ['tomatoes', 'basil']], [name: 'bed-1', plants: []]]

def toml = new TomlBuilder()
toml.greenhouse {
    name 'Backyard East'
    open true
    ratio 0.35
    sensors(['bench-1', 'bench-2'])
    limits {
        maxTemperature 35
    }
}
print toml
println '---- list of tables'
print TomlBuilder.toToml([title: 'Zones', zones: beds])
println '---- null'
print TomlBuilder.toToml([note: null, count: 0])
greenhouse.name = 'Backyard East'
greenhouse.open = true
greenhouse.ratio = 0.35
greenhouse.sensors = ['bench-1', 'bench-2']
greenhouse.limits.maxTemperature = 35
---- list of tables
title = 'Zones'
zones = [{name = 'north-bench', plants = ['tomatoes', 'basil']}, {name = 'bed-1', plants = []}]
---- null
note = ''
count = 0

Two details matter when the output is read by another program. A null is written as an empty string, note = '', because TOML has no null, so the information that the value was absent is lost. And a java.time value is written as a quoted string, not as a native TOML date, so a reader that is not typed sees a string.

import groovy.toml.*
import java.time.*

class Server { String host; int port; OffsetDateTime updated }
class Config { String name; LocalDate commissioned; Server server }

def original = new Config(name: 'Backyard East', commissioned: LocalDate.of(2026, 3, 1),
        server: new Server(host: 'localhost', port: 8080, updated: OffsetDateTime.parse('2026-03-01T08:30:00-08:00')))

def text = TomlBuilder.toToml(original)
print text

def back = new TomlSlurper().parseTextAs(Config, text)
println back.commissioned == original.commissioned
println back.server.updated == original.server.updated
println new TomlSlurper().parseText(text).commissioned.getClass().simpleName
name = 'Backyard East'
commissioned = '2026-03-01'
server.host = 'localhost'
server.port = 8080
server.updated = '2026-03-01T08:30:00-08:00'
true
true
String

The Groovy typed reader restores the original types, as the two true lines show, but the plain parse of the same text yields String. A configuration file meant to be edited by people is best written by hand, with native dates, and only read by the program.

Choosing between the formats

YAML TOML

Typing of unquoted values

guessed by the YAML 1.1 rules: NO, 02134 and 1.10 are surprising

explicit: strings are quoted, numbers and dates have their own syntax

Comments

#

#

Anchors, aliases, merge

in the language, not resolved by YamlSlurper

not in the language

Nesting

indentation, which makes deep documents compact and errors silent

tables and dotted keys, flat and explicit

Multiple documents in a file

yes, read as a list

no

Null

~, an empty value or null

none; TomlBuilder writes ''

Dates in the untyped result

String

String

Maturity in 6.0

stable since 3.0

stable in 6.0, incubating from 4.0

Use TOML for configuration that a person edits and a program reads, where the explicit typing removes a class of mistakes. Use YAML where the surrounding tooling already expects it, and read it with quoted strings for anything that is an identifier. For structured data exchanged between programs, JSON (JSON) is the safer choice.

Where the book is outdated

The Manning book Groovy in Action (publisher page in the references) was written for Groovy 1.0, long before either module existed.

  • There is no YAML or TOML support in the book. groovy-yaml arrived in Groovy 3.0 and groovy-toml in 4.0 (incubating), and TOML is stable in 6.0.

  • The parseAs and parseTextAs methods, the dateHandling property and the java.time binding are newer than the book, as is the use of Jackson underneath.

  • The anchors and aliases behaviour is a property of the current Jackson back end and may change with it; check the release notes when upgrading.

  • Version and JDK baselines: Groovy 6.0 needs JDK 17 or later.