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>
Source: Groovy documentation: Processing YAML and Processing TOML
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.10is the number 1.1; quote it ('1.10') when it is a version. -
zip: 02134is read as octal and gives 1116, and0x1Fas hexadecimal; quote anything that is an identifier. -
country: NOis the booleanfalse, the well-known "Norway problem", andYesistrue. A quoted'no'is aString. Quote country codes and any other value that happens to be a YAML boolean word. -
A key with no value and
~are bothnull.
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: |
explicit: strings are quoted, numbers and dates have their own syntax |
Comments |
|
|
Anchors, aliases, merge |
in the language, not resolved by |
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 |
|
none; |
Dates in the untyped result |
|
|
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-yamlarrived in Groovy 3.0 andgroovy-tomlin 4.0 (incubating), and TOML is stable in 6.0. -
The
parseAsandparseTextAsmethods, thedateHandlingproperty and thejava.timebinding 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.