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

heading

level, text, children

paragraph, block_quote, emphasis, strong

children

code_block

lang, text

list

ordered, items (and start for an ordered list)

list_item

text, children

link

href, title, text, children

image

src, title, alt

text

value

inline_code, html_block, html_inline

text

thematic_break, hard_line_break, soft_line_break

none

table

headers, alignments, rows

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 CsvSlurper and CsvBuilder instead of line.split(','), which breaks on the first quoted comma.

  • Both modules are incubating in 6.0 and depend on Jackson (CSV, java.time support) and CommonMark, which the distribution ships.

  • Groovy still has no streaming CSV reader in these classes: parse builds 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.