2026-07-11
XML didn't die — it just went into hiding. It's in every Maven POM, every Android manifest, every SVG, every .docx and .xlsx (they're zipped XML), every SOAP endpoint your bank still exposes, every RSS/Atom feed, every .NET config, every GPX track from your watch, every XLIFF translation dump. And what does everyone reach for? grep '<version>', which promptly breaks on attribute variants, namespaces, multi-line elements, or entity-escaped angle brackets.
xmlstarlet has been sitting in every distro's repos since 2002. It's a single C binary that speaks XPath 1.0, XSLT, C14N canonicalization, DTD/XSD/RelaxNG validation, and pretty-printing. Five subcommands cover it: sel, ed, fo, val, c14n, plus pyx to convert XML to a line-based format your existing awk pipelines can eat.
Extract every dependency from a Maven POM (note the namespace binding — this is where xmllint --xpath falls apart):
xmlstarlet sel -N m=http://maven.apache.org/POM/4.0.0 \
-t -m "//m:dependency" \
-v "concat(m:groupId,':',m:artifactId,':',m:version)" -n pom.xml
Edit in place — bump a project version, no sed regex terror:
xmlstarlet ed -L -N m=http://maven.apache.org/POM/4.0.0 \
-u "/m:project/m:version" -v "2.0.0" pom.xml
List every permission an Android APK asks for:
xmlstarlet sel -t -v "//uses-permission/@android:name" -n AndroidManifest.xml
Pull titles from an RSS feed as a one-liner:
curl -s https://xkcd.com/rss.xml \
| xmlstarlet sel -t -m "//item" -v "title" -o " | " -v "link" -n
Add or delete nodes — insert a new dependency, then delete all <scope>test</scope> ones:
xmlstarlet ed -L \
-s "/project/dependencies" -t elem -n dependency -v "" \
-s "/project/dependencies/dependency[last()]" -t elem -n groupId -v "org.junit" \
pom.xml
xmlstarlet ed -L -d "//dependency[scope='test']" pom.xml
Format ugly single-line XML (indent 2, preserve CDATA):
xmlstarlet fo -s 2 minified.xml
Validate against a schema, exit non-zero on failure — perfect for CI:
xmlstarlet val -e -s schema.xsd data.xml
The escape hatch: pyx. When XPath isn't ergonomic, convert to Sean McGrath's PYX line format — one element per line, prefixed with (, ), -, A — and pipe to awk:
xmlstarlet pyx big.xml | awk '/^-/ {print}' | sort | uniq -c
Why not xmllint --xpath? It's stripped-down: no clean namespace binding (you fight it with *[local-name()='foo']), no editing at all, no template loops, no formatted output with newlines between matches. xmlstarlet's -t -m -v -o -n template DSL is genuinely the jq equivalent for XML — you build the output shape declaratively.
Why not regex? Because <price currency="USD">5.00</price> and <price
currency='USD'
>5.00</price> mean the same thing to XML but different things to your regex, and someone will eventually commit the second form. XPath doesn't care about whitespace, attribute order, quote style, entity encoding, or namespace prefix aliasing. Regex cares about all of them, silently.
Install: apt install xmlstarlet, brew install xmlstarlet, dnf install xmlstarlet. Binary is ~200 KB. Zero runtime dependencies beyond libxml2, which is already on your machine.
