Skip to content
datahacks.dev
XML

Formatting XML With xmllint for Readable Diffs and Reviews

Pretty-print one-line XML with xmllint, set the indent, strip blank text for stable diffs, get canonical form and change encoding, and see what it alters.

7 min read
Summarize in AI
Flat illustration of paired angle-bracket tags wrapped around a document, with the XML logo.

Most XML you meet in a terminal is on one line, or indented by a machine that did not care about people. Reading it, diffing it and reviewing it all start with the same step: format it. xmllint ships with libxml2, is installed on most systems, and does this in one command. It also does a few other useful things to the layout, and each one has a consequence for what the document means.

The basic call

Shell
xmllint --format oneline.xml

The input was a single line: <?xml version="1.0"?><r><a x="1"><b>t</b><b>u</b></a><!-- c --><a x="2"/></r>. The output:

XML
<?xml version="1.0"?>
<r>
  <a x="1">
    <b>t</b>
    <b>u</b>
  </a>
  <!-- c -->
  <a x="2"/>
</r>

One element per line, two spaces of indent per level, the comment on its own line, and the empty element still written as a self-closing tag. The declaration is on its own line, and the file ends with a newline. The output goes to standard output; redirect it to a file, or use --output file.xml (-o). Do not redirect to the same file you are reading: the shell truncates it before xmllint reads, and you lose the document. Write to a new file and move it into place.

Indent width

The width is not a flag. It is controlled by the environment variable XMLLINT_INDENT, which holds the string used for one level:

Shell
XMLLINT_INDENT='    ' xmllint --format oneline.xml
Text
<r>
    <a x="1">
        <b>t</b>

Four spaces. A tab works the same way (XMLLINT_INDENT=$'\t' in bash). If the variable is not set, the default is two spaces. Set it in a Makefile or a script, not in your login, so that the whole team gets the same output; a formatting step that gives different results on two machines is not much of a formatting step.

Documents that are already indented

A document that has indentation already contains whitespace-only text between elements. xmllint treats that whitespace as content and keeps it, so formatting such a file can leave blank lines or mismatched indents, because it cannot tell which whitespace is yours and which is formatting. The option that fixes it is --noblanks, which removes whitespace-only text nodes while parsing, so the formatter starts from a clean tree:

Shell
xmllint --noblanks --format library.xml
Text
<?xml version="1.0" encoding="UTF-8"?>
<library updated="2026-10-01">
  <book id="b1" available="true">
    <title>Cache Patterns</title>
    <author>Ana Lee</author>
    <author>Bo Chen</author>

The standard recipe is --noblanks --format together, and it produces the same layout whatever the input looked like. That is what you want for a diff: two files with different layouts and the same content come out byte-identical after the pass, so diff shows only the real changes. This is the single most useful trick in the whole tool, and it turns "the diff shows every line changed" into "the diff shows two lines". The next step up, when attribute order or comments differ, is in comparing two XML documents for differences.

Canonical output

For a byte-stable form, --c14n writes Canonical XML:

Shell
xmllint --c14n oneline.xml
Text
<r><a x="1"><b>t</b><b>u</b></a><!-- c --><a x="2"></a></r>

Canonical form is not pretty. It is one line, the declaration is dropped, the self-closing <a x="2"/> has become <a x="2"></a>, attributes are sorted, and character references and namespace declarations are normalized. What it gives you is a single serialization for all documents that are equal as XML, which makes it the right input for hashing, signing and exact comparison. The comment was kept in this run (canonical XML has a with-comments and a without-comments variant, and --c14n printed the first). Use --c14n when you need bytes you can compare, and --format when you need text you can read.

Encoding

The output encoding is whatever you ask for. By default xmllint writes UTF-8 for a document with no declaration and keeps the declared encoding otherwise. To convert, use --encode:

Shell
xmllint --format --encode ISO-8859-1 oneline.xml | head -1
Text
<?xml version="1.0" encoding="ISO-8859-1"?>

The declaration in the output now says ISO-8859-1, and the bytes are Latin-1 (characters outside that range are written as numeric character references, so nothing is lost). Convert encodings with a tool built for it when you can, since an encoding change inside a formatter is easy to miss; but when a system insists on a legacy encoding, this is one command, and the declaration stays correct, which a plain iconv of the file would not do.

What formatting changes

Formatting is not free. It adds whitespace between elements, and whether that matters depends on the document.

For data (orders, configuration, feeds) the added whitespace is ignorable, and every consumer skips it. For mixed content it is not. A paragraph such as <p>Hello <b>big</b> world</p> formatted element by element becomes

XML
<p>Hello <b>big</b> world</p>

unchanged: I ran xmllint --format on a file with that paragraph and it was left on one line, because libxml2's formatter does not add whitespace inside an element that has text next to child elements. An element whose children are all elements, such as the q element in the same test file, did get its children re-indented, and a document with significant whitespace between elements (rare, but it exists in text-oriented markup) would change meaning. The test is cheap: format a copy, then compare the extracted text of both (xmllint --xpath 'string(/)'), after normalizing whitespace.

Formatting also invalidates signatures that cover the exact bytes or the canonical form of whitespace-bearing nodes, and it changes any checksum of the file. If a document is signed, do not format it in place; format a copy for reading (XML signature wrapping in plain language covers why the bytes matter). And it rewrites entity references: a file that used a character entity or a CDATA section may come back with an escaped equivalent, which is the same content, and a different text diff.

Putting it in a workflow

Three uses cover most of my day.

Reading a minified response. curl -s url | xmllint --format - formats from standard input (the dash). Add | less for a long one. If the input is not well-formed the output is partial and the errors go to standard error; fix the syntax first (pretty-printing XML to spot structural errors).

Normalizing before commit. A pre-commit hook that runs xmllint --noblanks --format over the XML files you keep in version control (fixtures, configuration, test data) keeps diffs small and removes the argument about indentation. Check the output into the same file and fail the hook if it changes, so formatting is a property of the repository.

Checking well-formedness as a side effect. A format run fails on a syntax error, so xmllint --noout file.xml is the cheap well-formedness gate, and formatting is its by-product.

The rest of the command-line toolbox is in handy command-line tools for XML, including extracting values with xmlstarlet and editing XML files in place from a script. The same task in Python is in pretty-printing XML in Python. The options are documented in the xmllint manual, and canonical XML in the W3C recommendation. Test a path in the XPath tester.

When formatting would change the meaning

The document is mixed content. Paragraphs with inline markup, poetry, code listings in XML and anything where spaces are text: do not reformat, or check the text before and after.

The document is signed or hashed. Format a copy.

You need the original bytes. A legal record, an evidence file or a message you must forward unchanged should be stored as received. Format a view of it.

The tool will reflow lines you care about. Some formats put long attribute lists or CDATA blocks where a human arranged them. A formatter will not preserve that arrangement.

Also inTooling
Taggedclihow-to
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.