XML: Creating & Manipulating

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). It is the writing half of the XML story: building documents with builders, editing a parsed tree, printing it back out and, new in 6.0, converting it to maps and typed objects. Reading XML, the two parsers, GPath and the secure-by-default rules are on XML Parsing, GPath & Security; read that page first, since the editing examples assume it. The builder idea in general (closures with a delegate that turn method calls into nodes) is on Builders and, for the delegate mechanics, Closure Delegation: Owner, Delegate & This. Kotlin’s version of the same idea is described on Kotlin Type-Safe Builders & DSLs.

The examples use the seed catalogue of the greenhouse, the same data/catalogue.xml as the parsing page. The XML classes are in groovy.xml, so each script starts with the imports it needs (import groovy.xml.MarkupBuilder and so on).

MarkupBuilder

groovy.xml.MarkupBuilder writes XML to a Writer as you call methods on it: a method call becomes an element, a map argument becomes its attributes, a string argument its text, and a closure its child elements. Nothing is built in memory; the markup is written as the closures run, which is why ordinary Groovy code (loops, conditions) can sit between the elements.

import groovy.xml.MarkupBuilder
import groovy.xml.XmlSlurper

def seeds = [
    [id: 's1', name: 'Tomato', stock: 120, price: '2.50', tags: ['fruit', 'warm']],
    [id: 's2', name: 'Basil',  stock: 0,   price: '1.80', tags: ['herb']]
]

def writer = new StringWriter()
def xml = new MarkupBuilder(writer)
xml.catalogue(season: 'spring') {
    supplier(id: 'sup1', 'Green Seeds Ltd')
    section(name: 'vegetables') {
        seeds.each { s ->
            seed(id: s.id, stock: s.stock) {
                name(s.name)
                price(currency: 'EUR', s.price)
                tags { s.tags.each { tag(it) } }
                if (s.stock == 0) soldOut()
            }
        }
    }
}
println writer

def check = new XmlSlurper().parseText(writer.toString())
assert check.section.seed.size() == 2
assert check.section.seed*.@id*.toString() == ['s1', 's2']
assert check.section.seed[1].soldOut.size() == 1
<catalogue season='spring'>
  <supplier id='sup1'>Green Seeds Ltd</supplier>
  <section name='vegetables'>
    <seed id='s1' stock='120'>
      <name>Tomato</name>
      <price currency='EUR'>2.50</price>
      <tags>
        <tag>fruit</tag>
        <tag>warm</tag>
      </tags>
    </seed>
    <seed id='s2' stock='0'>
      <name>Basil</name>
      <price currency='EUR'>1.80</price>
      <tags>
        <tag>herb</tag>
      </tags>
      <soldOut />
    </seed>
  </section>
</catalogue>

Notice the mixed form price(currency: 'EUR', s.price), where the map gives the attributes and the trailing value gives the text, and that an element without content (soldOut()) is written as a self-closing tag. Attribute values can be any object; they are converted with toString(). The trailing assert lines parse the generated text again with XmlSlurper and passed, which is a cheap way to test a builder. The default quote character for attributes is the single quote, which is valid XML.

Options, escaping and namespaces

MarkupBuilder has properties for the details: doubleQuotes switches to double quotes, omitNullAttributes and omitEmptyAttributes drop attributes with no value, and expandEmptyElements writes <tags></tags> instead of <tags />. Special characters in text and attributes are escaped for you. A different indentation comes from giving the builder an IndentPrinter, and a prefixed element name is quoted and the namespace declared with an xmlns: attribute.

import groovy.xml.MarkupBuilder

def w = new StringWriter()
def b = new MarkupBuilder(w)
b.doubleQuotes = true
b.omitNullAttributes = true
b.omitEmptyAttributes = true
b.expandEmptyElements = true
b.seed(id: 's9', origin: null, notes: '', stock: 5) { tags() }
println w

def w1 = new StringWriter()
new MarkupBuilder(w1).note(author: 'Tom & "Jerry"') { text('3 < 5 & 5 > 3'); empty() }
println w1

def w2 = new StringWriter()
new MarkupBuilder(new IndentPrinter(new PrintWriter(w2), '    ', true)).outer { inner { leaf('x') } }
println w2

def w3 = new StringWriter()
new MarkupBuilder(w3).'g:catalogue'('xmlns:g': 'urn:greenhouse:catalogue') { 'g:seed'(id: 's1') }
println w3
<seed id="s9" stock="5">
  <tags></tags>
</seed>
<note author='Tom &amp; "Jerry"'>
  <text>3 &lt; 5 &amp; 5 &gt; 3</text>
  <empty />
</note>
<outer>
    <inner>
        <leaf>x</leaf>
    </inner>
</outer>
<g:catalogue xmlns:g='urn:greenhouse:catalogue'>
  <g:seed id='s1' />
</g:catalogue>

There is one trap to know about. A local variable or method with the same name as an element captures the call, because Groovy resolves the name in the surrounding scope before the call reaches the builder.

def w = new StringWriter()
def b = new MarkupBuilder(w)
b.a { b { c('x') } }               // b is the local variable, not an element
println w

def w2 = new StringWriter()
def bld = new MarkupBuilder(w2)
bld.a { delegate.b { c('x') } }    // go through the delegate explicitly
println w2
<a>
  <call>
    <c>x</c>
  </call>
</a>
<a>
  <b>
    <c>x</c>
  </b>
</a>

In the first script b { ... } is b.call { ... } on the builder variable, so the output has an element named call; inside a builder closure use delegate.name(...), or give the builder a name that is not also an element name.

The mkp helper

Names inside a builder are elements, so the operations that are not elements, such as a comment, an XML declaration or a raw fragment, live on a special property named mkp (a groovy.xml.MarkupBuilderHelper, reachable on both builders).

Call Effect

mkp.xmlDeclaration(...)

Writes the XML declaration, with version, encoding and standalone attributes

mkp.comment('text')

Writes a comment

mkp.pi(target: [name: value])

Writes a processing instruction

mkp.yield('text')

Writes text with XML escaping, for example inside an element body

mkp.yieldUnescaped('<b/>')

Writes the text as it is, so you are responsible for it being well-formed markup

import groovy.xml.MarkupBuilder

def w = new StringWriter()
def m = new MarkupBuilder(w)
m.mkp.xmlDeclaration(version: '1.0', encoding: 'UTF-8')
m.mkp.comment('generated, do not edit')
m.catalogue {
    note('3 > n')
    mkp.yield('a < b')
    mkp.yieldUnescaped('<raw/>')
}
println w
<?xml version='1.0' encoding='UTF-8'?>
<!-- generated, do not edit --><catalogue>
  <note>3 &gt; n</note>a &lt; b<raw/>
</catalogue>

The declaration and comment are written before the root element, the escaped text became a < b, and the unescaped fragment was inserted verbatim. mkp.yieldUnescaped with input that you did not write yourself is an XML injection hole, since the text is no longer escaped.

StreamingMarkupBuilder

groovy.xml.StreamingMarkupBuilder takes a different approach: bind does not write anything, it returns a Writable (a closure that implements Writable) which produces the markup when it is converted to a string or written to a Writer. It is the right choice when the same template is written several times, or streamed straight into a response. It writes compact output without indentation, and the mkp helper is available inside the closure given to bind.

import groovy.xml.StreamingMarkupBuilder

def seeds = [[id: 's1', name: 'Tomato', stock: 120], [id: 's2', name: 'Basil', stock: 0]]
def built = new StreamingMarkupBuilder().bind {
    mkp.xmlDeclaration()
    mkp.comment('generated')
    catalogue(season: 'spring') {
        seeds.each { s ->
            seed(id: s.id, stock: s.stock) { name(s.name) }
        }
        note { mkp.yield('3 < 5') }
        raw { mkp.yieldUnescaped('<b>bold</b>') }
    }
}
println built.getClass().name
println built

def sw = new StringWriter()
built.writeTo(sw)
println sw.toString().size()

def ns = new StreamingMarkupBuilder().bind {
    mkp.declareNamespace(g: 'urn:greenhouse:catalogue')
    g.catalogue { g.seed(id: 's1') }
}
println ns

def pit = new StreamingMarkupBuilder().bind { root { note(mkp.yield('3 < 5')) } }
println pit
groovy.lang.Closure$WritableClosure
<?xml version='1.0'?>
<!--generated--><catalogue season='spring'><seed id='s1' stock='120'><name>Tomato</name></seed><seed id='s2' stock='0'><name>Basil</name></seed><note>3 &lt; 5</note><raw><b>bold</b></raw></catalogue>
221
<g:catalogue xmlns:g='urn:greenhouse:catalogue'><g:seed id='s1'/></g:catalogue>
<root>3 &lt; 5<note>3 &lt; 5</note></root>

Differences from MarkupBuilder that show up above: namespaces are declared with mkp.declareNamespace and used as a property prefix (g.catalogue), and mkp.yield is meant to be called as a statement inside the element’s closure. The last script is the trap: note(mkp.yield('3 < 5')) calls mkp.yield as an argument, so the text is written first, before the note element starts, and the returned string becomes the content as well, giving the duplicated 3 < 5. Always write note { mkp.yield(...) }. A builder passed to a helper closure, and a bind closure that loops over data, work the same as on MarkupBuilder.

DomToGroovy

When you have a document and want builder code for it as a head start, org.apache.groovy.xml.tools.DomToGroovy converts a DOM Document into MarkupBuilder source. It is an authoring aid: the output is a starting point for a script, not a finished program.

import groovy.xml.DOMBuilder
import org.apache.groovy.xml.tools.DomToGroovy

def doc = DOMBuilder.parse(new StringReader('''
<section name="flowers">
  <seed id="s3" stock="45">
    <name>Marigold</name>
    <tags><tag>companion</tag><tag>warm</tag></tags>
  </seed>
</section>'''))
def out = new StringWriter()
new DomToGroovy(new PrintWriter(out)).print(doc)
println out
section(name:'flowers') {
  seed(id:'s3', stock:'45') {
    name('Marigold')
    tags() {
      tag('companion')
      tag('warm')
    }
  }
}

The official page goes one step further and evaluates the generated text with a GroovyShell to show that the round trip works. That is a demonstration only: evaluating generated source runs whatever the document produced, so do it only for documents you control (see Compilation Customizers & Secure Scripting for running untrusted Groovy). Note also that DOMBuilder.parse applies the secure parsing defaults, so a document with a DOCTYPE needs the relax knobs described on the parsing page.

Manipulating a parsed document

The two parsers differ most when you edit. A Node from XmlParser is an ordinary mutable tree: every change is visible at once. A GPathResult from XmlSlurper is a lazy view: structural changes are recorded and only show up after the document is written out and parsed again, while attribute changes are immediate. Both accept the same closure syntax as MarkupBuilder for the new content.

Editing with XmlParser

import groovy.xml.XmlParser
import groovy.xml.XmlNodePrinter
import groovy.namespace.QName

def parser = new XmlParser(trimWhitespace: true)
def cat = parser.parseText(new File('data/catalogue.xml').text)

// add
def flowers = cat.section.find { it.@name == 'flowers' }
def nas = flowers.appendNode('seed', [id: 's4', stock: '10'])
nas.appendNode('name', 'Nasturtium')
nas.appendNode('price', [currency: 'EUR'], '1.00')
println flowers.seed*.@id
println flowers.seed[-1].name.text()           // visible at once

def note = parser.createNode(cat, new QName('note'), [:])
note.value = 'hand-picked'
println cat.children().collect { it.name() }

// modify
cat.@updated = '2026-10-14'
def tomato = cat.section[0].seed[0]
tomato.price[0].value = '2.75'
tomato.price[0].@currency = 'USD'
println cat.@updated
println tomato.price.text() + ' ' + tomato.price.@currency

// replace
cat.section[1].seed[0].replaceNode {
    seed(id: 's5', stock: '7') { name('Calendula') }
}
println cat.section[1].seed*.@id
println cat.section[1].seed[0].name.text()

// remove: ask the parent
def basil = cat.section[0].seed.find { it.@id == 's2' }
println cat.section[0].remove(basil)
println cat.section[0].seed*.@id
println cat.section[1].seed[0].attributes().remove('stock')
println cat.section[1].seed[0].attributes()

def sw = new StringWriter()
def printer = new XmlNodePrinter(new PrintWriter(sw))
printer.preserveWhitespace = true
printer.print(cat.section[1])
println sw
[s3, s4]
Nasturtium
[supplier, section, section, note]
2026-10-14
2.75 [USD]
[s5, s4]
Calendula
true
[s1]
7
[id:s5]
<section name="flowers">
  <seed id="s5">
    <name>Calendula</name>
  </seed>
  <seed id="s4" stock="10">
    <name>Nasturtium</name>
    <price currency="EUR">1.00</price>
  </seed>
</section>

The operations, as the run shows them:

  • appendNode(name, attributes, value) adds a child to a Node (the attribute map and the value are optional) and returns the new node, so you can chain appendNode calls on it. parser.createNode(parent, new QName('note'), [:]) is the lower-level form that takes a groovy.namespace.QName and a map of attributes; set the content with node.value.

  • An attribute is written with node.@name = 'value', and text content is replaced by assigning node.value.

  • replaceNode { ... } swaps a node for whatever the closure builds, using the MarkupBuilder syntax. The new seed was visible immediately because this is a Node.

  • There is no delete on the node: removal is the parent’s job, parent.remove(node) (returns true when the child was there), and an attribute is removed from the map returned by attributes().

  • appendNode adds at the end, so seed[-1] is the last child, and the printed section shows the new seed s4 after the one that replaceNode put in the place of s3.

Editing with XmlSlurper

import groovy.xml.StreamingMarkupBuilder
import groovy.xml.XmlSlurper

def cat = new XmlSlurper().parseText(new File('data/catalogue.xml').text)
def reparse = { new XmlSlurper().parseText(new StreamingMarkupBuilder().bind { mkp.yield cat }.toString()) }

cat.@season = 'summer'                                   // attributes: visible at once
println cat.@season

cat.section[0].seed[0].replaceNode {
    seed(id: 's9', stock: '3') { name('Courgette') }
}
println cat.section[0].seed[0].name.text()                // the old node: change is queued
println reparse().section[0].seed[0].name.text()          // after serialising and parsing again

cat.section[1].appendNode { seed(id: 's7') { name('Nigella') } }
println reparse().section[1].seed*.@id

cat.section[0].seed.find { it.@id == 's2' }.replaceNode { }   // an empty replacement removes the node
println reparse().section[0].seed*.@id

cat.supplier.replaceBody('Blue Seeds Ltd')
println reparse().supplier.text()
println new StreamingMarkupBuilder().bind { mkp.yield cat }.toString().take(80)
summer
Tomato
Courgette
[s3, s7]
[s9]
Blue Seeds Ltd
<catalogue season='summer'><supplier id='sup1'>Blue Seeds Ltd</supplier><section

The same operations exist with different names. replaceNode with a closure replaces a node, an empty closure removes it, appendNode and replaceBody add and rewrite content, and the attribute assignment works on the spot, but anything structural is invisible until you evaluate the document again. The way to do that is the one in the official documentation: write the GPathResult with StreamingMarkupBuilder (mkp.yield result) and parse that text, as the reparse closure does. The result of the first println after the replaceNode still shows the old Tomato, and the re-parsed document shows Courgette. The slurper has no createNode; that exists only on XmlParser. Choosing between them follows from this: if you read the tree between edits use XmlParser, and if you make many edits and pass the document on, the slurper (a transformer) is a good fit. The last line also shows that StreamingMarkupBuilder output has no XML declaration unless you add mkp.xmlDeclaration().

Printing XML

Three tools print a document, and they are not interchangeable. XmlUtil.serialize works for a String, a GPathResult, a Node, a DOM element and more, and returns an indented string; a SerializeOptions controls the encoding, the indent and the DOCTYPE and external-resource behaviour (the last two are the relax knobs of the parsing page). groovy.xml.XmlNodePrinter prints a Node with its own indentation rules. StreamingMarkupBuilder with mkp.yield prints a GPathResult, as above.

import groovy.xml.*

def text = '<seed id="s1"><name>Tomato</name><price currency="EUR">2.50</price></seed>'

// from a String and from a GPathResult: clean output
println XmlUtil.serialize(text)
def slurped = new XmlSlurper().parseText(text)
println XmlUtil.serialize(slurped) == XmlUtil.serialize(text)
println XmlUtil.serialize(slurped, new SerializeOptions(indent: 4))
println XmlUtil.serialize(slurped, new SerializeOptions(encoding: 'ISO-8859-1')).readLines().first()

// from a Node: use XmlNodePrinter
def node = new XmlParser().parseText(text)
def sw = new StringWriter()
new XmlNodePrinter(new PrintWriter(sw)).print(node)
println sw
def sw2 = new StringWriter()
def printer = new XmlNodePrinter(new PrintWriter(sw2), '    ')
printer.preserveWhitespace = true
printer.print(node)
println sw2

// XmlUtil.serialize on a Node containing nested elements adds whitespace-only lines on this JDK
println XmlUtil.serialize(node).readLines().collect { it.isBlank() ? '<blank>' : it }
<?xml version="1.0" encoding="UTF-8"?><seed id="s1">
  <name>Tomato</name>
  <price currency="EUR">2.50</price>
</seed>

true
<?xml version="1.0" encoding="UTF-8"?><seed id="s1">
    <name>Tomato</name>
    <price currency="EUR">2.50</price>
</seed>

<?xml version="1.0" encoding="ISO-8859-1"?><seed id="s1">
<seed id="s1">
  <name>
    Tomato
  </name>
  <price currency="EUR">
    2.50
  </price>
</seed>

<seed id="s1">
    <name>Tomato</name>
    <price currency="EUR">2.50</price>
</seed>

[<?xml version="1.0" encoding="UTF-8"?><seed id="s1">, <blank>,   <name>Tomato</name>, <blank>,   <price currency="EUR">2.50</price>, <blank>, </seed>]

Reading the output: XmlUtil.serialize writes the declaration on the same line as the root element, indent: 4 and the encoding option take effect, and a slurped document prints the same as its source text. By default XmlNodePrinter puts element text on its own indented lines; setting preserveWhitespace keeps <name>Tomato</name> on one line, and the second constructor argument is the indent string. The last line is the surprise: XmlUtil.serialize of a Node that has nested elements printed whitespace-only lines between them on this JDK, so prefer XmlNodePrinter for nodes. The official example serialises a text-only Node (a single author element), which does not show this.

Every XmlUtil.serialize call in this run also printed two lines on standard error (not shown above), from groovy.xml.FactorySupport warnHardeningNotApplied: WARNING: Unable to apply XML security setting 'http://apache.org/xml/features/disallow-doctype-decl'=true on com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl, followed by the JDK’s reason and "the factory may be more permissive than intended". Groovy 6 tries to harden every XML factory it creates (secure by default, see XML Parsing, GPath & Security); the JDK 26 TransformerFactory does not support that one feature, and Groovy reports each setting it cannot apply instead of failing. The warning does not change the result: the serialized text on standard output is the same with or without it. It is logged through java.util.logging, so it can be silenced by raising the level of the groovy.xml.FactorySupport logger. Running the same script with JAVA_OPTS=-Djava.util.logging.config.file=quiet.properties, where quiet.properties holds the lines handlers=java.util.logging.ConsoleHandler, .level=INFO and groovy.xml.FactorySupport.level=SEVERE, printed nothing on standard error. In code, Logger.getLogger('groovy.xml.FactorySupport').level = Level.SEVERE worked only when the logger was kept in a variable; the one-line form, with no reference kept, still printed the warning. The Logger.getLogger API documentation explains why: the logging framework may discard a logger that nothing references, and its level with it.

Typed XML conversion (Groovy 6.0)

Groovy 6.0 adds conversion from parsed XML to maps and typed objects, as a complement to dynamic GPath. It starts from a Node, so it is a feature of XmlParser.

Nodes to maps

Node.toMap() turns a node into a nested Map. Attributes and child elements are merged into one map, repeated same-name children become a list, and an element that has both attributes and text keeps the text under the key _text (the constant Node.TEXT_KEY). If a child element and an attribute have the same name the child wins. All values are `String`s, and no extra dependency is required.

import groovy.xml.XmlParser

def seedXml = '''<seed id="s1" stock="120">
  <name>Tomato</name>
  <price currency="EUR">2.50</price>
  <tags><tag>fruit</tag><tag>warm</tag></tags>
</seed>'''

def node = new XmlParser().parseText(seedXml)
def map = node.toMap()
println map
println map.price
println map.tags
println Node.TEXT_KEY
println new XmlParser().parseText('<config><alias>web1</alias><alias>web2</alias></config>').toMap()
[id:s1, stock:120, name:Tomato, price:[currency:EUR, _text:2.50], tags:[tag:[fruit, warm]]]
[currency:EUR, _text:2.50]
[tag:[fruit, warm]]
_text
[alias:[web1, web2]]

The shape follows the XML, not what you would want in an object model: the tags are [tag: [...]], not a list, because the tags element wraps the repeated tag elements, and the price is a map because it has an attribute.

Coercion with as

Because the result is a standard Map, Groovy’s as coercion can build an object from it through the named-parameter constructor (see Type Coercion & Promotion), and as Map returns the map. The properties must be `String`s for this to work without help, and a property of the XML that the class lacks is an error.

class SeedHeader { String id; String stock; String name }

def flat = new XmlParser().parseText('<seed id="s1" stock="120"><name>Tomato</name></seed>')
def header = flat as SeedHeader
println header.id + ' ' + header.name + ' ' + header.stock.getClass().simpleName
Map asMap = flat as Map
println asMap.keySet()
try { node as SeedHeader } catch (MissingPropertyException e) { println e.message }
s1 Tomato String
[id, stock, name]
No such property: price for class: SeedHeader

The last line used the full seed from the previous example, which has a price and tags that SeedHeader does not declare. For other types, conversion, or nested objects use parseTextAs.

Typed parsing with parseTextAs

XmlParser.parseTextAs(Type, text) (and parseAs for a Reader, InputStream, File or Path) parses the XML into the map shown above and hands it to Jackson’s ObjectMapper.convertValue, so the types are converted (String to int, BigDecimal, nested objects, lists) and Jackson annotations such as @JsonProperty and @JsonFormat apply. It needs jackson-databind on the classpath, which the groovy command has; in a build add the dependency yourself, because a clear error is raised when it is absent.

import groovy.xml.XmlParser
import com.fasterxml.jackson.annotation.JsonProperty
import groovy.transform.ToString

@ToString(includeNames = true) class Price { String currency; @JsonProperty('_text') BigDecimal amount }
@ToString(includeNames = true) class Tags { List<String> tag }
@ToString(includeNames = true) class Seed { String id; String name; int stock; Price price; Tags tags }

def seedXml = '''<seed id="s1" stock="120">
  <name>Tomato</name>
  <price currency="EUR">2.50</price>
  <tags><tag>fruit</tag><tag>warm</tag></tags>
</seed>'''
def seed = new XmlParser().parseTextAs(Seed, seedXml)
println seed
println seed.stock.getClass().simpleName + ' ' + seed.price.amount.getClass().simpleName
println seed.tags.tag

class Bad { String id; int stock }
try { new XmlParser().parseTextAs(Bad, seedXml) } catch (Throwable e) {
    def t = e; while (t.cause) t = t.cause
    println t.class.simpleName + ': ' + t.message.readLines().first().take(130)
}
Seed(id:s1, name:Tomato, stock:120, price:Price(currency:EUR, amount:2.50), tags:Tags(tag:[fruit, warm]))
Integer BigDecimal
[fruit, warm]
UnrecognizedPropertyException: Unrecognized field "name" (class Bad), not marked as ignorable (3 known properties: "id", "stock", "metaClass")

stock arrived as an int and the price as a BigDecimal. Two details follow from the map shape: the text of an element that also has attributes is reached through the _text key, mapped here with @JsonProperty('_text'), and a wrapper element such as tags becomes its own class (Tags), because this path uses jackson-databind on the map, not an XML data format. The failure is Jackson’s strictness: a property the class does not declare is an error unless the class is annotated @JsonIgnoreProperties(ignoreUnknown = true); and the metaClass property that Jackson counts as "known" is the Groovy object’s own accessor.

Full Jackson XML

parseTextAs does not use jackson-dataformat-xml, so the XML-specific annotations (@JacksonXmlText, @JacksonXmlElementWrapper, @JacksonXmlProperty) have no effect there. For those, use the data format directly through XmlMapper, which also writes XML. The example needs the library, so it uses @Grab (see Grape Dependency Management).

@Grab('com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.22.2')
import com.fasterxml.jackson.dataformat.xml.XmlMapper
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlText
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper
import groovy.transform.ToString
import groovy.xml.XmlParser
import groovy.xml.XmlUtil

@ToString(includeNames = true) class Price { @JacksonXmlProperty(isAttribute = true) String currency; @JacksonXmlText BigDecimal amount }
@ToString(includeNames = true) class Seed {
    @JacksonXmlProperty(isAttribute = true) String id
    @JacksonXmlProperty(isAttribute = true) int stock
    String name
    Price price
    @JacksonXmlElementWrapper(localName = 'tags') @JacksonXmlProperty(localName = 'tag') List<String> tags
}

def seedXml = '<seed id="s1" stock="120"><name>Tomato</name><price currency="EUR">2.50</price><tags><tag>fruit</tag><tag>warm</tag></tags></seed>'
def mapper = new XmlMapper()
def seed = mapper.readValue(seedXml, Seed)
println seed
println mapper.writeValueAsString(seed)

// inspect with XmlParser first, then hand the serialized text to Jackson
def node = new XmlParser().parseText(seedXml)
assert node.@stock == '120'
println mapper.readValue(XmlUtil.serialize(node), Seed).name
Seed(id:s1, stock:120, name:Tomato, price:Price(currency:EUR, amount:2.50), tags:[fruit, warm])
<Seed id="s1" stock="120"><name>Tomato</name><price currency="EUR">2.50</price><tags><tag>fruit</tag><tag>warm</tag></tags></Seed>
Tomato

The model is now closer to the XML: attributes, the element text and the list wrapper are declared with annotations, and the same XmlMapper writes the object back (the root element is named after the class). The last lines show the other route the documentation suggests: parse with XmlParser to inspect or validate, then serialize and give the text to Jackson. The run printed the same two-line TransformerFactory hardening WARNING on standard error as the printing example above, from the XmlUtil.serialize(node) call: Groovy 6 could not apply disallow-doctype-decl to the JDK 26 TransformerFactory and said so, and the warning did not affect the result (the name Tomato was read as shown). The version 2.22.2 matches the Jackson jars of the Groovy 6.0.0 distribution that was used; pick the version your build already uses for other Jackson modules. The JSON side of this, with JsonSlurper and parseTextAs, is on JSON.

Where the book is outdated

The Manning book Groovy in Action (publisher page in the references) shows MarkupBuilder, StreamingMarkupBuilder and in-place editing of parsed trees, and those concepts are unchanged. Check these points against the current documentation:

  • The classes are in groovy.xml (the groovy-xml module), not groovy.util, so older listings need new imports.

  • Typed conversion (toMap, as, parseTextAs) is new in 6.0, and SerializeOptions gives XmlUtil.serialize control over encoding and indentation.

  • Parsers and XmlUtil.serialize are secure by default in 6.0, which affects documents with a DOCTYPE (see the parsing page).

  • The SOAP and XML-RPC chapters are not covered; exchange XML over plain HTTP instead (see HTTP Client with HttpBuilder).

  • Baselines: Groovy 6.0 needs JDK 17 or later.