CSV & Markdown
|
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). Groovy 6.0 added two small modules for text formats that have no standard library support in the JDK:
groovy-csv, with CsvSlurper and CsvBuilder for CSV (RFC 4180), and groovy-markdown, with MarkdownSlurper for
CommonMark Markdown. Both are incubating in 6.0, so their API can still change in a later release. Both follow the pattern of
the older data-format classes: a ...Slurper parses text into plain lists and maps and a ...Builder writes them back. The
same pattern is on JSON, YAML & TOML
and XML Parsing, GPath & Security.
The examples use two of the greenhouse files. data/readings.csv is a sensor log with gaps (an empty temperature in the second
data row and an empty humidity in the third), and notes/care.md holds care notes. The files are shown where they are first used.
Both modules are in the lib directory of the Groovy 6.0.0 binary distribution, together with the Jackson and CommonMark jars
they depend on, so the scripts below need no @Grab; in a build they are the artifacts org.apache.groovy:groovy-csv and
org.apache.groovy:groovy-markdown (see Build Tools: Gradle, Maven & Ant).
Reading CSV with CsvSlurper
CsvSlurper parses CSV text into a List of Map objects. The first row is the header and every other row becomes a map keyed
by the header names. All values are strings. It can read from a String (parseText), a Reader, an InputStream, a File
or a Path (parse), the same overloads as JsonSlurper. The data file is:
sensor,at,temperature,humidity,soil
bench-1,2026-10-14T06:00:00Z,18.5,71,0.32
bench-1,2026-10-14T06:05:00Z,,70,0.31
basket-2,2026-10-14T06:00:00Z,19.0,,0.45
bed-3,2026-10-14T06:00:00Z,17.25,66,0.28
import groovy.csv.CsvSlurper
def rows = new CsvSlurper().parse(new File('data/readings.csv'))
println rows.size()
println rows[0]
println rows[1].temperature.isEmpty()
println rows[2].humidity.getClass().name
def temps = rows.findAll { it.temperature }.collect { it.temperature as BigDecimal }
println temps
println rows*.sensor.unique()
4
[sensor:bench-1, at:2026-10-14T06:00:00Z, temperature:18.5, humidity:71, soil:0.32]
true
java.lang.String
[18.5, 19.0, 17.25]
[bench-1, basket-2, bed-3]
A gap in the data is the empty string, not null, and the empty string is false under the Groovy truth
(Booleans & the Groovy Truth), so findAll { it.temperature }
drops the rows that have no temperature. Because every row is a map, the GDK methods of
Working with Collections apply directly, and the header names are
usable as properties (it.temperature).
Configuration
The separator and the quote character are set with setSeparator and setQuoteChar. Each setter returns the slurper, so calls
chain. Both take a char, and a Groovy single-quoted string is a String, hence the (char) cast. Quoted fields follow RFC 4180:
a field that contains the separator, a newline or the quote character is enclosed in quotes, and an embedded quote is doubled.
import groovy.csv.CsvSlurper
def tsv = new CsvSlurper().setSeparator((char) '\t').parseText('sensor\tnote\nbench-1\tdrip line\nbed-3\tnew label')
println tsv
def quoted = new CsvSlurper().parseText('sensor,note\nbench-1,"leaf curl, lower leaves"\nbed-3,"said ""dry"""')
println quoted[0].note
println quoted[1].note
def multi = new CsvSlurper().parseText('sensor,note\nbed-3,"line one\nline two"')
println multi[0].note.readLines()
[[sensor:bench-1, note:drip line], [sensor:bed-3, note:new label]]
leaf curl, lower leaves
said "dry"
[line one, line two]
CSV without a header row
When the file has no header row, call setUseHeader(false). The columns are then named c1, c2 and so on, or by the names given
to setColumns. If setColumns is called while the header is still on, the first row is consumed and discarded as a header, and the
supplied names win; that is a quick way to rename the columns of a file you do not control.
import groovy.csv.CsvSlurper
def bare = new CsvSlurper().setUseHeader(false).parseText('bench-1,18.5\nbed-3,17.25')
println bare
def named = new CsvSlurper().setUseHeader(false).setColumns('sensor', 'temperature').parseText('bench-1,18.5\nbed-3,17.25')
println named
def relabelled = new CsvSlurper().setColumns('id', 'ts', 't', 'rh', 'soil').parse(new File('data/readings.csv'))
println relabelled[0]
[[c1:bench-1, c2:18.5], [c1:bed-3, c2:17.25]]
[[sensor:bench-1, temperature:18.5], [sensor:bed-3, temperature:17.25]]
[id:bench-1, ts:2026-10-14T06:00:00Z, t:18.5, rh:71, soil:0.32]
How ragged files behave is worth knowing before trusting a feed. A row with fewer cells than the header gives a map with only the
cells that are present (the run printed [[a:1]] for a,b followed by 1), while a row with more cells than the header makes the
parser throw a CsvReadException from the Jackson CSV layer.
Typed parsing
parseAs(Type, source) uses Jackson data binding to produce typed objects instead of maps, so the numbers and dates are converted for
you. A Groovy record works as the target; the cells map to the record components by name, and the empty cells of the sample file
become null rather than "". Standard Jackson annotations (@JsonProperty, @JsonFormat, @JsonPropertyOrder) control the
column names, formats and order.
import groovy.csv.CsvSlurper
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.annotation.JsonPropertyOrder
import java.time.Instant
record Reading(String sensor, Instant at, BigDecimal temperature, Integer humidity, BigDecimal soil) { }
def readings = new CsvSlurper().parseAs(Reading, new File('data/readings.csv'))
println readings.size()
println readings[0]
println readings[1].temperature
println readings[2].humidity
println readings[0].at.getClass().name
// headerless: @JsonPropertyOrder gives the column order
@JsonPropertyOrder(['sensor', 'temperature'])
class Sample { String sensor; BigDecimal temperature }
def s = new CsvSlurper().setUseHeader(false).parseAs(Sample, 'bench-1,18.5\nbed-3,17.25')
println s*.temperature.sum()
// column renaming with @JsonProperty
class Renamed { @JsonProperty('sensor') String id; BigDecimal soil }
println new CsvSlurper().parseAs(Renamed, 'sensor,soil\nbed-3,0.28').first().id
4
Reading[sensor=bench-1, at=2026-10-14T06:00:00Z, temperature=18.5, humidity=71, soil=0.32]
null
null
java.time.Instant
35.75
bed-3
The target types are declared at the top level of the script. The documentation writes them as static class, which is the form to
use when the types are nested inside another class; a top-level class in a script cannot be static, and the compiler rejects
the modifier. The untyped API never converts anything: a cell that looks like a date stays a string, and only parseAs with
java.time fields gives dates (see Round trips and dates).
Writing CSV with CsvBuilder
CsvBuilder.toCsv(collectionOfMaps) writes a collection of maps. The keys of the first map become the header, and a null value
becomes an empty cell. Cells that contain the separator or the quote character are quoted the way RFC 4180 says. A later map that
has a key the first one lacks is an error, so give every map the same keys.
import groovy.csv.CsvBuilder
def maps = [
[sensor: 'bench-1', note: 'leaf curl, lower leaves', temperature: 18.5],
[sensor: 'bed-3', note: 'said "dry"', temperature: null]
]
print CsvBuilder.toCsv(maps)
println '--'
print new CsvBuilder().setSeparator((char) ';').call(maps)
println '--'
try { CsvBuilder.toCsv([[a: 1, b: 2], [a: 3, c: 4]]) } catch (e) { println e.class.simpleName + ': ' + e.message.readLines()[0] }
sensor,note,temperature
bench-1,"leaf curl, lower leaves",18.5
bed-3,"said ""dry""",
--
sensor;note;temperature
"bench-1";"leaf curl, lower leaves";18.5
"bed-3";"said ""dry""";
--
CsvWriteException: Unrecognized column 'c': known columns: ["a","b"] (through reference chain: java.util.ArrayList[1]->java.util.LinkedHashMap["c"])
The instance form (new CsvBuilder().setSeparator(...).call(rows)) is configurable. In this run, the form with a custom separator
also quoted every string cell, not only the ones that needed it, so do not compare the text of the two forms byte for byte. A
CsvBuilder is a Writable, so it can stream into a file or an HTTP response without building the whole String. The fragment
reuses the maps list of the previous example:
import groovy.csv.CsvBuilder
def out = new File('out-readings.csv')
out.withWriter { w -> new CsvBuilder().call(maps).writeTo(w) }
println out.readLines().size()
3
Typed writing
CsvBuilder.toCsv(collection, Type) writes typed objects with the same Jackson annotations as the reading side. The column order is
the component order for a record, and alphabetical for any other class, unless the class carries @JsonPropertyOrder.
import groovy.csv.CsvBuilder
import com.fasterxml.jackson.annotation.JsonPropertyOrder
import java.time.Instant
record Reading(String sensor, Instant at, BigDecimal temperature, Integer humidity, BigDecimal soil) { }
def rs = [new Reading('bench-1', Instant.parse('2026-10-14T06:00:00Z'), 18.5, 71, 0.32),
new Reading('bed-3', Instant.parse('2026-10-14T06:00:00Z'), null, 66, 0.28)]
print CsvBuilder.toCsv(rs, Reading)
println '--'
class Plant { String name; String zone; int waterEveryHours }
print CsvBuilder.toCsv([new Plant(name: 'tomato', zone: 'bench-1', waterEveryHours: 2)], Plant)
println '--'
@JsonPropertyOrder(['name', 'zone', 'waterEveryHours'])
class OrderedPlant { String name; String zone; int waterEveryHours }
print CsvBuilder.toCsv([new OrderedPlant(name: 'tomato', zone: 'bench-1', waterEveryHours: 2)], OrderedPlant)
sensor,at,temperature,humidity,soil
bench-1,2026-10-14T06:00:00Z,18.5,71,0.32
bed-3,2026-10-14T06:00:00Z,,66,0.28
--
name,waterEveryHours,zone
tomato,2,bench-1
--
name,zone,waterEveryHours
tomato,bench-1,2
Round trips and dates
CSV written by CsvBuilder can be read back by CsvSlurper, and with a typed class the round trip is lossless, including the
java.time types: LocalDate, LocalTime, LocalDateTime, OffsetDateTime (the non-UTC offset is kept) and Instant. The
example logs a watering and ends with a small report, the average temperature per sensor written back as CSV.
import groovy.csv.CsvBuilder
import groovy.csv.CsvSlurper
import java.time.*
record Watering(String zone, LocalDate day, LocalTime start, OffsetDateTime logged, int litres) { }
def log = [new Watering('bench-1', LocalDate.of(2026, 10, 14), LocalTime.of(6, 0),
OffsetDateTime.parse('2026-10-14T06:00:05+02:00'), 12)]
def csv = CsvBuilder.toCsv(log, Watering)
print csv
def back = new CsvSlurper().parseAs(Watering, csv)
assert back == log
println back[0].logged.offset
// the untyped API keeps everything as text
println new CsvSlurper().parseText(csv)[0].day.getClass().simpleName
// summary: average temperature per sensor, written back as CSV
def rows = new CsvSlurper().parse(new File('data/readings.csv'))
def summary = rows.findAll { it.temperature }
.groupBy { it.sensor }
.collect { sensor, rs -> [sensor: sensor, avgTemperature: (rs.sum { it.temperature as BigDecimal } / rs.size()).setScale(2)] }
print CsvBuilder.toCsv(summary)
zone,day,start,logged,litres
bench-1,2026-10-14,06:00:00,"2026-10-14T06:00:05+02:00",12
+02:00
String
sensor,avgTemperature
bench-1,18.50
basket-2,19.00
bed-3,17.25
Reading Markdown with MarkdownSlurper
MarkdownSlurper parses CommonMark Markdown (using the CommonMark Java library) into a MarkdownDocument. The document is
backed by nested lists and maps: each node is a Map with a type key and type-specific fields, and children holds the nested
nodes. The point is to consume Markdown, for example care notes, changelogs or the output of a language model, not to render it
to HTML. The parse methods mirror the other slurpers: parseText, and parse for a Reader, InputStream, File or Path. The
sample file is:
# Greenhouse care notes
Water the **tomatoes** every two hours, and check the [drip line](http://localhost:8080/drip) weekly.
## Tomatoes
> Remove side shoots on `bench-1` before they grow.
- Feed weekly
- Stake after *first* flowers
```groovy
water 'tomatoes' every 2.hours
```
## Basket plants
1. Mist at dawn
2. Rotate monthly
```text
dawn mist
```
The document is iterable over its top-level nodes (nodes is the same list), and it has convenience properties that walk the whole
tree: headings, codeBlocks, links, tables, and text, the plain text of the document.
import groovy.markdown.MarkdownSlurper
def doc = new MarkdownSlurper().parse(new File('notes/care.md'))
println doc.nodes*.type
println doc.headings.collect { "${it.level}:${it.text}" }
println doc.codeBlocks.collect { [it.lang, it.text.trim()] }
println doc.links.collect { [it.text, it.href] }
println doc.nodes[1]
[heading, paragraph, heading, block_quote, list, code_block, heading, list, code_block]
[1:Greenhouse care notes, 2:Tomatoes, 2:Basket plants]
[[groovy, water 'tomatoes' every 2.hours], [text, dawn mist]]
[[drip line, http://localhost:8080/drip]]
[type:paragraph, children:[[type:text, value:Water the ], [type:strong, children:[[type:text, value:tomatoes]]], [type:text, value: every two hours, and check the ], [type:link, href:http://localhost:8080/drip, title:null, text:drip line, children:[[type:text, value:drip line]]], [type:text, value: weekly.]]]
codeBlocks and links search the entire tree, including blocks nested in block quotes and list items, so they find the fenced
blocks and links wherever they sit. A fenced block has lang (the info string) and text (which keeps its final newline, hence
the trim() above), which makes "pull every groovy snippet out of this reply" a one-liner:
doc.codeBlocks.findAll { it.lang == 'groovy' }*.text.
Sections
section('Heading text') returns the nodes between that heading and the next heading of the same or a higher level (here
Tomatoes ends at Basket plants, both level 2). That suits structured notes with a fixed set of headings.
The fragment continues with the doc variable of the previous example.
println doc.section('Tomatoes')*.type
println doc.section('Basket plants')[0].items*.text
println doc.section('Basket plants')[0].ordered
for (node in doc) { if (node.type == 'block_quote') println node }
[block_quote, list, code_block]
[Mist at dawn, Rotate monthly]
true
[type:block_quote, children:[[type:paragraph, children:[[type:text, value:Remove side shoots on ], [type:inline_code, text:bench-1], [type:text, value: before they grow.]]]]]
Tables
GFM-style tables need the optional org.commonmark:commonmark-ext-gfm-tables jar, which is not in the Groovy distribution, and
a call to enableTables(true). Each row is then a map keyed by the header, and headers and alignments are available on the
table. Without enableTables the same text is a plain paragraph. The script resolves the jar with Grape;
@GrabConfig(systemClassLoader = true) makes it visible to the slurper (see
Grape Dependency Management).
@GrabConfig(systemClassLoader = true)
@Grab('org.commonmark:commonmark-ext-gfm-tables:0.30.0')
import groovy.markdown.MarkdownSlurper
def md = '''\
| sensor | zone | soil |
|---------|:------:|-----:|
| bench-1 | Bench | 0.32 |
| bed-3 | Bed | 0.28 |
'''
def doc = new MarkdownSlurper().enableTables(true).parseText(md)
def t = doc.tables[0]
println t.headers
println t.alignments
println t.rows
println t.rows.sum { it.soil as BigDecimal }
// without enableTables the table is just a paragraph
println new MarkdownSlurper().parseText(md).nodes*.type
[sensor, zone, soil]
[null, center, right]
[[sensor:bench-1, zone:Bench, soil:0.32], [sensor:bed-3, zone:Bed, soil:0.28]]
0.60
[paragraph]
Node types
The type values and their fields, as listed by the documentation and seen in the runs above:
type |
Fields |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
none |
|
|
Raw HTML in the Markdown is kept as html_block or html_inline nodes with the source text. The slurper never interprets or
sanitises it.
Untrusted input
MarkdownSlurper is a convenience parser, not a security boundary, like JsonSlurper and XmlSlurper. Prefer trusted sources, and
bound the input size yourself before parsing anything you did not write. As a backstop it limits nesting (block containers and
emphasis) to 1000 levels by default, and a document that goes deeper is rejected with a MarkdownRuntimeException instead of
exhausting the stack. The limit is the maxNestingDepth property, or the groovy.markdown.maxNestingDepth system property for
every slurper, and a value of zero or less disables the check.
import groovy.markdown.*
def s = new MarkdownSlurper()
println s.maxNestingDepth
s.maxNestingDepth = 100
try {
s.parseText(('>' * 5000) + ' text')
} catch (MarkdownRuntimeException e) {
println e.class.simpleName + ': ' + e.message
}
println s.parseText('> ' * 50 + 'ok').nodes.size()
1000
MarkdownRuntimeException: Maximum Markdown nesting depth of 100 exceeded
1
Where the book is outdated
The Manning book Groovy in Action (publisher page in the references) predates both modules, so it cannot describe them; code written in its era reads CSV with plain string methods. These points differ from that era:
-
Since 6.0, quoted fields, embedded newlines, custom separators and typed conversion are handled by
CsvSlurperandCsvBuilderinstead ofline.split(','), which breaks on the first quoted comma. -
Both modules are incubating in 6.0 and depend on Jackson (CSV,
java.timesupport) and CommonMark, which the distribution ships. -
Groovy still has no streaming CSV reader in these classes:
parsebuilds the whole list in memory. For multi-gigabyte files use Jackson’s CSV iterator directly or process the file line by line with Files I/O & Paths.