JSON

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). JSON is the format most Groovy programs meet first, and the groovy.json package turns it into plain maps and lists and back with very little code: JsonSlurper reads, JsonOutput and JsonGenerator write, JsonBuilder and StreamingJsonBuilder describe a document with the builder syntax covered in Builders. The results of parsing are ordinary collections, so everything on Working with Collections and Maps applies to them. The sibling formats are on YAML & TOML and XML Parsing, GPath & Security; the closure syntax used by the builders is on Closures.

The groovy-json module

JSON support lives in its own artifact, groovy-json. It is part of the Groovy distribution, so a script run with the groovy command needs no setup. A project that embeds Groovy declares the dependency, and a script that runs outside the distribution can use @Grab.

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

The examples below share one data file from the greenhouse scenario, data/plants.json.

{
  "greenhouse": "Backyard East",
  "plants": [
    { "name": "tomatoes", "species": "Solanum lycopersicum", "waterEveryHours": 2, "minMoisture": 0.35, "indoor": true, "notes": null },
    { "name": "basil", "species": "Ocimum basilicum", "waterEveryHours": 6, "minMoisture": 0.5, "indoor": true, "notes": "pinch flowers" },
    { "name": "chillies", "species": "Capsicum annuum", "waterEveryHours": 12, "minMoisture": 0.25, "indoor": false, "notes": null }
  ]
}

Reading JSON with JsonSlurper

JsonSlurper has parseText for a String and parse overloads for a File, a Path, a Reader, an InputStream, a URL and byte or character arrays. The result is a Map for a JSON object, a List for an array, and the natural JVM type for a scalar.

import groovy.json.JsonSlurper

def doc = new JsonSlurper().parse(new File('data/plants.json'))

println doc.greenhouse
println doc.plants*.name
println doc.plants.findAll { it.indoor }*.name
println doc.plants.find { it.name == 'basil' }.minMoisture
println doc.plants.sum { it.waterEveryHours }
println doc.plants.notes
println doc.plants[1].containsKey('notes')
println doc.plants[0].containsKey('colour')
println doc.plants[0].colour
Backyard East
[tomatoes, basil, chillies]
[tomatoes, basil]
0.5
20
[null, pinch flowers, null]
true
false
null

Because the result is made of maps and lists, the property syntax, the spread operator and the GDK methods work directly: doc.plants*.name collects one property from every element. A key that is absent gives null rather than an error, which is the same as a key whose value is JSON null; use containsKey when the difference matters. A JSON null is the Groovy null, not a library placeholder object. The top level of the document does not have to be an object: a bare array parses to a list, and a bare string or number to a String or a number.

How JSON types map to Groovy types

import groovy.json.JsonSlurper

def values = new JsonSlurper().parseText('''
  { "simple": 123, "fraction": 123.66, "exponential": 123e12, "big": 12345678901,
    "flag": true, "nothing": null, "text": "x", "list": [1, 2], "map": {"k": 1} }
  /* the default parser accepts comments */''')

values.each { key, value ->
    println "${key.padRight(12)}${value?.getClass()?.name} = $value"
}
simple      java.lang.Integer = 123
fraction    java.math.BigDecimal = 123.66
exponential java.math.BigDecimal = 1.23E+14
big         java.lang.Long = 12345678901
flag        java.lang.Boolean = true
nothing     null = null
text        java.lang.String = x
list        java.util.ArrayList = [1, 2]
map         org.apache.groovy.json.internal.LazyMap = [k:1]
JSON Groovy

string

java.lang.String

number without fraction or exponent

Integer, or Long when it does not fit

number with fraction or exponent

java.math.BigDecimal, never Double

object

a Map; the concrete class depends on the parser variant (see below), not LinkedHashMap

array

java.util.ArrayList

true, false

Boolean

null

null

date-like string

java.util.Date for two parser variants only (see Date-like strings)

Numbers with a fraction parse to BigDecimal, so decimal values such as 0.35 keep their exact digits (see Numbers & Numeric Literals). The default parser also accepts /* */ comments, as the example shows, which strict JSON does not allow.

Choosing a parser variant

JsonSlurper delegates to one of four parser implementations, selected by JsonParserType through the type property. The default is CHAR_BUFFER.

Constant Behaviour Use it for

CHAR_BUFFER (default)

works on the character array and copies sub-arrays ("chopping") for values

general use

INDEX_OVERLAY

keeps indexes into the original buffer and creates values as late as possible; fastest

documents up to about 2 MB, short-lived results

LAX

the index-overlay parser with a relaxed grammar: comments, bare keys, single quotes

hand-written configuration

CHARACTER_SOURCE

reads through a window over the input with constant memory

files larger than about 2 MB

import groovy.json.*

def text = '{"id":"S-7","when":"2026-09-04T10:00:00.000Z","day":"2026-09-04"}'

JsonParserType.values().each { type ->
    def map = new JsonSlurper(type: type).parseText(text)
    println "${type.name().padRight(17)}${map.getClass().simpleName.padRight(14)}" +
            "when: ${map.when.getClass().simpleName.padRight(7)} day: ${map.day.getClass().simpleName}"
}
println "default parser: ${new JsonSlurper().type}"
INDEX_OVERLAY    LazyValueMap  when: Date    day: String
CHARACTER_SOURCE LazyMap       when: String  day: String
LAX              LazyValueMap  when: Date    day: String
CHAR_BUFFER      LazyMap       when: String  day: String
default parser: CHAR_BUFFER

The map classes differ: CHAR_BUFFER and CHARACTER_SOURCE return LazyMap, while INDEX_OVERLAY and LAX return LazyValueMap. Both are java.util.Map implementations, so code written against Map is unaffected, but neither is a LinkedHashMap.

import groovy.json.*

def source = '{"a":1,"list":[1,2]}'

def doc = new JsonSlurper().parseText(source)
doc.b = 2
doc.list << 3
println doc
println doc == [a: 1, list: [1, 2, 3], b: 2]
println doc instanceof Map
println doc instanceof LinkedHashMap

def overlay = new JsonSlurper(type: JsonParserType.INDEX_OVERLAY).parseText(source)
println overlay.getClass().simpleName
try {
    overlay.b = 2
} catch (e) {
    println e.message
}
println overlay.a      // reading is fine
[a:1, list:[1, 2, 3], b:2]
true
true
false
LazyValueMap
Not that kind of map
1

An index-overlay map is effectively read-only: adding a key throws JsonInternalException. If the result is going to be modified or cached, use the default parser, or copy it first with new LinkedHashMap(map) (a shallow copy). The LAX variant accepts input that the standard grammar rejects.

import groovy.json.*

def relaxed = '''{ name: 'basil',   // single quotes, bare keys and a trailing comma
  hours: 6, }'''

println new JsonSlurper(type: JsonParserType.LAX).parseText(relaxed)

try {
    new JsonSlurper().parseText(relaxed)
} catch (JsonException e) {
    println e.message.readLines().first()
}
try {
    new JsonSlurper().parseText('{"a": ')
} catch (JsonException e) {
    println e.message.readLines().first()
}
try {
    new JsonSlurper().parseText('')
} catch (IllegalArgumentException e) {
    println e.message
}
[name:basil, hours:6]
expecting '}' or ',' but got current char 'n' with an int value of 110
Unable to determine the current character, it is not a string, number, array, or object
Text must not be null or empty

Syntax errors throw groovy.json.JsonException, and an empty or null string throws IllegalArgumentException, so configuration loaders usually wrap both. The first line of the message points at the problem; the rest of it echoes the offending input with a caret, which can be long.

Date-like strings

Two parser variants, INDEX_OVERLAY and LAX, convert strings that look like a full ISO-8601 timestamp into java.util.Date; CHAR_BUFFER and CHARACTER_SOURCE never do. A date without a time, such as 2026-09-04, stays a String in all four. In Groovy 6.0 the conversion is controlled by JsonDateHandling, set with setDateHandling.

import groovy.json.*

def text = '{"offset":"2026-09-04T10:00:00+10:00"}'

JsonDateHandling.values().each { handling ->
    def value = new JsonSlurper(type: JsonParserType.INDEX_OVERLAY)
            .setDateHandling(handling)
            .parseText(text).offset
    def shown = value instanceof Date ? value.toInstant() : value
    println "${handling.name().padRight(17)}${value.getClass().simpleName.padRight(15)}$shown"
}

// the older switch: false keeps the String
def raw = new JsonSlurper(type: JsonParserType.INDEX_OVERLAY).setCheckDates(false).parseText(text)
println raw.offset.getClass().simpleName
STRING           String         2026-09-04T10:00:00+10:00
UTIL_DATE        Date           2026-09-04T00:00:00Z
INSTANT          Instant        2026-09-04T00:00:00Z
OFFSET_DATE_TIME OffsetDateTime 2026-09-04T10:00+10:00
String
  • STRING keeps the text, UTIL_DATE is the default for the two converting parsers, and both Date and Instant denote an instant and so lose the +10:00 offset the document carried. Only OFFSET_DATE_TIME keeps it.

  • The older setCheckDates(false) still works and means STRING, but it is deprecated.

  • The value printed for UTIL_DATE above is converted with toInstant() because printing a Date uses the default time zone of the machine.

Limits on untrusted input

A parser that reads a request body or an uploaded file should not trust its size. JsonSlurper has two limits, both defaulting to 1000: maxNestingDepth (how deeply arrays and objects may be nested) and maxNumberLength (the digits in a single number). Exceeding either throws JsonException instead of exhausting the stack or the heap.

import groovy.json.*

def slurper = new JsonSlurper()
println "maxNestingDepth: ${slurper.maxNestingDepth}, maxNumberLength: ${slurper.maxNumberLength}"

def attempt = { Closure parse ->
    try { println parse().getClass().simpleName } catch (JsonException e) { println e.message.readLines().first() }
}

attempt { slurper.parseText('[' * 1001 + ']' * 1001) }
attempt { slurper.parseText('[' * 1000 + ']' * 1000) }
attempt { new JsonSlurper(maxNestingDepth: 3).parseText('[[[[1]]]]') }
attempt { new JsonSlurper(maxNumberLength: 5).parseText('[1234567]') }

// every parser type enforces it, though the message differs
JsonParserType.values().each { type ->
    print "${type.name().padRight(17)}"
    attempt { new JsonSlurper(type: type, maxNestingDepth: 3).parseText('[[[[1]]]]') }
}
maxNestingDepth: 1000, maxNumberLength: 1000
Maximum JSON nesting depth of 1000 exceeded
ArrayList
Maximum JSON nesting depth of 3 exceeded
JSON number length of 7 exceeds the maximum of 5
INDEX_OVERLAY    Maximum JSON nesting depth of 3 exceeded
CHARACTER_SOURCE Unexpected issue
LAX              Maximum JSON nesting depth of 3 exceeded
CHAR_BUFFER      Maximum JSON nesting depth of 3 exceeded

Set a lower maxNestingDepth for documents whose shape you know, and keep the overall input size bounded before parsing, because the limits cap structure, not length. The CHARACTER_SOURCE variant enforces the depth limit but reports it with a different message.

Writing JSON with JsonOutput

JsonOutput.toJson is static and overloaded for the common types. It writes maps, collections, arrays, numbers, strings, enums, UUID, URL, Date, Calendar, Expando, records and plain Groovy objects (POGOs), by reading their properties. The result is compact JSON with no whitespace.

import groovy.json.JsonOutput

class Plant {
    String name
    int waterEveryHours
    String notes
    transient String scratch
}

def basil = new Plant(name: 'basil', waterEveryHours: 6, scratch: 'tmp')

println JsonOutput.toJson(basil)
println JsonOutput.toJson([sensor: 'bench-1', values: [21.5, 22, 'n/a', true, null], tags: [], extra: [:]])
println JsonOutput.toJson('plain text')
println JsonOutput.toJson(42)
println JsonOutput.toJson([name: 'Café', quote: 'say "hi"'])
println JsonOutput.toJson(Optional.of(3))
println JsonOutput.toJson(new Date(0))
{"name":"basil","waterEveryHours":6,"notes":null,"scratch":"tmp"}
{"sensor":"bench-1","values":[21.5,22,"n/a",true,null],"tags":[],"extra":{}}
"plain text"
42
{"name":"Caf\u00e9","quote":"say \"hi\""}
{"empty":false,"present":true}
"1970-01-01T00:00:00+0000"

Points to read in that output:

  • Property order follows the order of declaration; map order follows the map.

  • A null property is written as null; excludeNulls (below) removes it.

  • A transient field is written, because JsonOutput follows properties, not Java serialization rules. Use excludeFieldsByName to keep a field out.

  • Non-ASCII characters are escaped as é by default, which is valid JSON and safe in any encoding.

  • A Date is written as an ISO-8601 string in UTC, and a java.util.Optional is written as its two bean properties, empty and present, which is almost never what a client wants; unwrap it first.

The java.time types are not special-cased, so they are written as bean graphs. A converter fixes that (see the next section), and a non-finite number is refused because JSON cannot represent it.

import groovy.json.JsonOutput
import java.time.*

println JsonOutput.toJson(Instant.parse('2026-09-04T10:00:00Z'))
println JsonOutput.toJson([day: LocalDate.of(2026, 9, 4)]).take(70)
try {
    JsonOutput.toJson([ratio: Double.NaN])
} catch (groovy.json.JsonException e) {
    println e.message
}
{"epochSecond":1788516000,"nano":0}
{"day":{"chronology":{"calendarType":"iso8601","id":"ISO","isoBased":t
Number NaN can't be serialized as JSON: NaN are not allowed in JSON.

Customizing the output with JsonGenerator

JsonGenerator.Options is a fluent builder; build() returns a JsonGenerator that can be kept and reused for any number of calls. Its options are excludeNulls, excludeFieldsByName, excludeFieldsByType, dateFormat, timezone, disableUnicodeEscaping and addConverter.

import groovy.json.JsonGenerator
import java.time.Instant

class Reading {
    String sensor
    Instant at
    BigDecimal temperature
    String note
    String location
}

def reading = new Reading(sensor: 'bench-1', at: Instant.parse('2026-09-04T10:00:00Z'),
        temperature: 21.5, location: 'north bench')

def generator = new JsonGenerator.Options()
        .excludeNulls()
        .excludeFieldsByName('location')
        .addConverter(Instant) { Instant instant -> instant.toString() }
        .build()

println generator.toJson(reading)
println generator.toJson([reading, [when: Instant.EPOCH]])
{"sensor":"bench-1","at":"2026-09-04T10:00:00Z","temperature":21.5}
[{"sensor":"bench-1","at":"2026-09-04T10:00:00Z","temperature":21.5},{"when":"1970-01-01T00:00:00Z"}]

A converter is a closure registered for a type; it is called for that type and its subtypes. Its first parameter must match the registered type, and an optional second String parameter receives the property name, or null inside a list.

import groovy.json.JsonGenerator
import java.time.Instant

def generator = new JsonGenerator.Options()
        .addConverter(Instant) { Instant instant, String key ->
            key == 'at' ? instant.epochSecond : instant.toString()
        }
        .excludeFieldsByType(BigDecimal)
        .build()

println generator.toJson([at: Instant.EPOCH, other: Instant.EPOCH, temperature: 21.5, hours: 6])
println generator.toJson([Instant.EPOCH])    // no key inside a list

try {
    new JsonGenerator.Options().addConverter(Date) { Calendar calendar -> }
} catch (IllegalArgumentException e) {
    println e.message
}
{"at":0,"other":"1970-01-01T00:00:00Z","hours":6}
["1970-01-01T00:00:00Z"]
Expected first parameter to be of type: class java.util.Date

Dates have their own options. Without them, a Date is written in UTC with a numeric offset.

import groovy.json.*

def planted = Date.from(java.time.Instant.parse('2026-03-01T08:30:00Z'))

println new JsonGenerator.Options().build().toJson([planted: planted])
println new JsonGenerator.Options().dateFormat('yyyy-MM-dd').timezone('UTC').build().toJson([planted: planted])
println new JsonGenerator.Options().dateFormat('EEE d MMM', Locale.FRANCE).timezone('UTC').build().toJson([planted: planted])

println JsonOutput.toJson([name: 'Café'])
println new JsonGenerator.Options().disableUnicodeEscaping().build().toJson([name: 'Café'])
println JsonOutput.toJson([raw: JsonOutput.unescaped('{"already":"json"}')])
{"planted":"2026-03-01T08:30:00+0000"}
{"planted":"2026-03-01"}
{"planted":"dim. 1 mars"}
{"name":"Caf\u00e9"}
{"name":"Café"}
{"raw":{"already":"json"}}

JsonOutput.unescaped marks a string that is already JSON so that it is embedded as is, which is useful for a fragment built elsewhere and a hazard for untrusted text. A generator can also be passed to the constructor of JsonBuilder and StreamingJsonBuilder.

Pretty-printing

JsonOutput.prettyPrint reformats a JSON string with four-space indentation. It works on any JSON text, not only on the output of toJson, and takes an optional flag to leave non-ASCII characters unescaped.

import groovy.json.JsonOutput

def json = JsonOutput.toJson([greenhouse: 'East', plants: [[name: 'basil', hours: 6]], empty: [], none: [:]])
println json
println JsonOutput.prettyPrint(json)
println JsonOutput.prettyPrint('{"café":"é"}')
println JsonOutput.prettyPrint('{"café":"é"}', true)
println JsonOutput.prettyPrint('{"not":"valid" "json"}')
{"greenhouse":"East","plants":[{"name":"basil","hours":6}],"empty":[],"none":{}}
{
    "greenhouse": "East",
    "plants": [
        {
            "name": "basil",
            "hours": 6
        }
    ],
    "empty": [

    ],
    "none": {

    }
}
{
    "caf\u00e9": "\u00e9"
}
{
    "café": "é"
}
{
    "not": "valid""json"
}

prettyPrint is a text re-flow, not a validator: the last line shows that invalid input is reformatted without complaint. Empty arrays and objects are printed across several lines.

Building JSON with JsonBuilder

JsonBuilder collects a structure built with the builder syntax and writes it on demand with toString() or toPrettyString(). Each call inside the closure becomes a property, a nested closure becomes a nested object, and a call that takes a collection becomes an array; name(collection) { element -> ... } builds one object per element.

import groovy.json.JsonBuilder

def stock = [[name: 'basil', hours: 6, tags: ['herb']], [name: 'tomatoes', hours: 2, tags: []]]

def json = new JsonBuilder()
json.greenhouse {
    name 'Backyard East'
    open true
    schedule stock.collect { [plant: it.name, everyHours: it.hours] }
    zones(stock) { plant ->
        id plant.name.toUpperCase()
        tagCount plant.tags.size()
    }
    contact {
        nickname 'gardener'
    }
}

println json.toString()
println json.toPrettyString().readLines().take(8).join('\n')
println json.content.getClass().simpleName
println json.content.greenhouse.name
{"greenhouse":{"name":"Backyard East","open":true,"schedule":[{"plant":"basil","everyHours":6},{"plant":"tomatoes","everyHours":2}],"zones":[{"id":"BASIL","tagCount":1},{"id":"TOMATOES","tagCount":0}],"contact":{"nickname":"gardener"}}}
{
    "greenhouse": {
        "name": "Backyard East",
        "open": true,
        "schedule": [
            {
                "plant": "basil",
                "everyHours": 6
LinkedHashMap
Backyard East

The content is held as a map in content, which can still be changed before the document is written. The builder also accepts a root array or map, names that are not identifiers, computed names and a configured generator.

import groovy.json.*

// a root array, a map, and names that are not identifiers
def list = new JsonBuilder()
list([1, 2, 3])
println list

def fromMap = new JsonBuilder([a: 1, b: [c: 2]])
println fromMap

def odd = new JsonBuilder()
def key = 'dynamic'
odd {
    "needs quotes" 1
    "$key" 2
    delegate.'class' 'x'
    nothing null
}
println odd

// a generator applies to builders too
def trimmed = new JsonBuilder(new JsonGenerator.Options().excludeNulls().build())
trimmed { x null; y 1 }
println trimmed

// content stays editable until it is written
def edited = new JsonBuilder([a: 1])
edited.content.b = 2
println edited
[1,2,3]
{"a":1,"b":{"c":2}}
{"needs quotes":1,"dynamic":2,"class":"x","nothing":null}
{"y":1}
{"a":1,"b":2}

One trap comes from the builder syntax itself: a node name is a method call, so a local variable with the same name as a node is used as the receiver of the call instead.

import groovy.json.JsonBuilder

def plants = [[name: 'basil'], [name: 'mint']]
def json = new JsonBuilder()
try {
    json.catalogue {
        plants plants.collect { it.name }        // plants is also a local variable
    }
} catch (MissingMethodException e) {
    println e.message.readLines().first().take(110)
}

def fixed = new JsonBuilder()
fixed.catalogue {
    delegate.plants plants.collect { it.name }   // or rename the local variable
}
println fixed
No signature of method: call for class: java.util.ArrayList is applicable for argument types: (ArrayList) valu
{"catalogue":{"plants":["basil","mint"]}}

The first form fails because plants plants.collect { ... } is read as calling the variable plants with an argument. Writing delegate.plants, or choosing different names for variables and nodes, fixes it. The explicit delegate. receiver is also how a node called class, which would otherwise be read as the class property, is written in the previous example. How a builder closure resolves such names is on Closure Delegation: Owner, Delegate & This.

Streaming large documents with StreamingJsonBuilder

StreamingJsonBuilder has the same syntax but writes to a Writer as it goes instead of building a map first, so the memory used does not grow with the document. The price is that the output is already written when a call returns, so nothing can be edited afterwards. It takes the same optional JsonGenerator.

import groovy.json.*

def out = new StringWriter()
def stream = new StreamingJsonBuilder(out)
stream.reading {
    sensor 'bench-1'
    values([21.5, 22.0])
    meta {
        unit 'C'
    }
}
println out

def list = new StringWriter()
new StreamingJsonBuilder(list).call([1, 2, 3]) { n -> value n * 2 }
println list

def file = File.createTempFile('readings', '.json')
file.withWriter('UTF-8') { writer ->
    new StreamingJsonBuilder(writer).call((1..3).collect { [id: it, temperature: 20 + it] })
}
println file.text
file.delete()

def trimmed = new StringWriter()
new StreamingJsonBuilder(trimmed, new JsonGenerator.Options().excludeNulls().build()).call { a null; b 'x' }
println trimmed
{"reading":{"sensor":"bench-1","values":[21.5,22.0],"meta":{"unit":"C"}}}
[{"value":2},{"value":4},{"value":6}]
[{"id":1,"temperature":21},{"id":2,"temperature":22},{"id":3,"temperature":23}]
{"b":"x"}

Choose JsonBuilder when the document is small or needs to be inspected or changed before it is sent, and StreamingJsonBuilder when the output goes straight to a file, a socket or an HTTP response; the third block above writes three readings to a file that way.

Typed parsing

JSON is usually read into a class, not navigated as a map. JsonSlurper has no parseTextAs method (the YAML and TOML slurpers do, see YAML & TOML); the documented route is Groovy’s as coercion, which needs no dependency.

import groovy.json.JsonSlurper

enum Season { SPRING, SUMMER }

class Plant {
    String name
    int waterEveryHours
    BigDecimal minMoisture
    boolean indoor
    String toString() { "Plant($name, every ${waterEveryHours}h, moisture $minMoisture, indoor $indoor)" }
}

class Greenhouse {
    String greenhouse
    List<Plant> plants
    Season season
}

def plant = new JsonSlurper().parseText('{"name":"basil","waterEveryHours":6,"minMoisture":0.5,"indoor":true}') as Plant
println plant
println((new JsonSlurper().parseText('{"season":"SUMMER"}') as Greenhouse).season)

def greenhouse = new JsonSlurper().parse(new File('data/plants.json')) as Greenhouse
println greenhouse.greenhouse
println greenhouse.plants[0].getClass().simpleName
Plant(basil, every 6h, moisture 0.5, indoor true)
SUMMER
Backyard East
LazyMap

The first three lines work, including the conversion of "SUMMER" to an enum constant. The last line shows the limit: generics are erased at run time, so the plants list is still a list of maps and its elements are not Plant objects. Convert the elements yourself (doc.plants.collect { it as Plant }) or use a library that sees the generic type. A record is built the same way from a map whose keys match its components.

as also fails quietly, which is the part to be careful with.

import groovy.json.JsonSlurper

class Plant {
    String name
    int waterEveryHours
    String toString() { "Plant($name, every ${waterEveryHours}h)" }
}

def asPlant = { String json -> new JsonSlurper().parseText(json) as Plant }

println asPlant('{"name":"basil","waterEveryHours":6}')
println asPlant('{"name":"basil","waterEveryHours":6,"colour":"green"}')   // unknown key
println asPlant('{"name":"basil","waterEveryHours":"soon"}')               // wrong type
println asPlant('{"name":"basil","waterEveryHours":"6"}')                   // numeric string
println asPlant('{"name":"basil","waterEveryHours":2.9}')                   // fraction

try {
    new Plant(new JsonSlurper().parseText('{"name":"basil","colour":"green"}'))
} catch (MissingPropertyException e) {
    println e.message
}
Plant(basil, every 6h)
Plant(null, every 0h)
Plant(null, every 0h)
Plant(basil, every 54h)
Plant(basil, every 2h)
No such property: colour for class: Plant

An unknown key (colour) or a value of the wrong type ("soon") produces an object with default values and no error. A numeric string is converted through a character code ("6" becomes 54), and a fraction is truncated. If you want an exception, call the constructor yourself, as the last lines do: new Plant(map) throws MissingPropertyException for an unknown key. For strict mapping, nested generic types, dates and annotations, use Jackson directly, as the official documentation suggests. Jackson ships in the distribution’s lib directory (the YAML and TOML modules depend on it); a project declares com.fasterxml.jackson.core:jackson-databind itself.

import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper

class Plant {
    String name
    int waterEveryHours
    BigDecimal minMoisture
    boolean indoor
}

class Greenhouse {
    String greenhouse
    List<Plant> plants
}

def mapper = new ObjectMapper()
try {
    mapper.readValue(new File('data/plants.json'), Greenhouse)
} catch (e) {
    println e.getClass().simpleName
    println e.message.readLines().first()
}

mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
def greenhouse = mapper.readValue(new File('data/plants.json'), Greenhouse)
println greenhouse.plants*.getClass()*.simpleName.unique()
println greenhouse.plants[1].with { "$name $waterEveryHours $minMoisture $indoor" }
println mapper.writeValueAsString(greenhouse.plants[0])
UnrecognizedPropertyException
Unrecognized field "species" (class Plant), not marked as ignorable (5 known properties: "minMoisture", "name", "waterEveryHours", "indoor", "metaClass")
[Plant]
basil 6 0.5 true
{"name":"tomatoes","waterEveryHours":2,"minMoisture":0.35,"indoor":true}

Jackson rejects the species key because the class has no such property, which is the strictness as lacks, and with that check switched off it builds a real List<Plant>. The metaClass property in the message is Groovy’s own, which Jackson sees on every Groovy object; it is harmless here. Source: Groovy documentation: Typed JSON parsing

Reading, changing and writing back

The pieces combine into the usual load, modify, save cycle. The default parser returns mutable collections, so no copy is needed.

import groovy.json.*

def source = new File('data/plants.json')
def data = new JsonSlurper().parse(source)

data.plants.each { it.waterEveryHours *= 2 }
data.plants << [name: 'mint', species: 'Mentha spicata', waterEveryHours: 4,
                minMoisture: 0.6, indoor: true, notes: null]

def copy = File.createTempFile('plants', '.json')
copy.setText(JsonOutput.prettyPrint(JsonOutput.toJson(data)), 'UTF-8')
def again = new JsonSlurper().parse(copy)
println again.plants.collect { "${it.name}: every ${it.waterEveryHours}h" }
println again == data
println JsonOutput.toJson(again.plants*.subMap(['name', 'indoor']))
copy.delete()
[tomatoes: every 4h, basil: every 12h, chillies: every 24h, mint: every 4h]
true
[{"name":"tomatoes","indoor":true},{"name":"basil","indoor":true},{"name":"chillies","indoor":false},{"name":"mint","indoor":true}]

Quick reference

Task Call

Parse a string

new JsonSlurper().parseText(text)

Parse a file, path or reader

new JsonSlurper().parse(file)

Parse a very large file

new JsonSlurper(type: JsonParserType.CHARACTER_SOURCE).parse(file)

Allow comments and bare keys

new JsonSlurper(type: JsonParserType.LAX)

Keep a time-zone offset

setDateHandling(JsonDateHandling.OFFSET_DATE_TIME) on INDEX_OVERLAY or LAX

Limit hostile input

new JsonSlurper(maxNestingDepth: 50, maxNumberLength: 30)

Write compact JSON

JsonOutput.toJson(object)

Write indented JSON

JsonOutput.prettyPrint(JsonOutput.toJson(object))

Control fields, dates, types

new JsonGenerator.Options()...build().toJson(object)

Build with the DSL

JsonBuilder (in memory) or StreamingJsonBuilder (to a Writer)

Read into a class

json as Type for simple cases, Jackson ObjectMapper otherwise

Where the book is outdated

The Manning book Groovy in Action (publisher page in the references) predates JSON support in the library, so its text has no equivalent of this page.

  • JSON support arrived in Groovy 1.8 (2011) and has been in the separate groovy-json module since the 2.0 split, so the dependency is not on the core classpath of an older build.

  • The parser variants, the JsonDateHandling options, JsonGenerator and the nesting and number-length limits are all newer than the book and are documented here for the 6.0 line.

  • Typed parsing in current Groovy is as coercion or Jackson, not a built-in parseTextAs on JsonSlurper.

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