XML to CSV Converter

Flatten to CSV, and be told when the structure will not fit.

Input
Output
WaitingPaste a document to check it. Validation runs as you type.

Everything runs in this tab. Nothing you paste is uploaded, logged or sent anywhere. Open your network panel and check.

Paste a record-list XML document and it becomes a comma-separated table you can open in Excel or load with pandas. The row element is detected for you, nested children become dotted column names such as address.city, and attributes become columns prefixed with @. The scanner, the flattener and the CSV writer run in a Web Worker in this tab, so nothing is uploaded and nothing is gated behind an account.

You reach for this when something upstream only speaks XML and something downstream only speaks rows: a product feed, a bank statement export, a report out of an old ERP, an API response you want to eyeball before writing code against it. It is also the quickest way to get a sitemap into a column of URLs.

What is different is that the conversion tells you what it could not represent. CSV is a rectangle and XML is a tree, so some documents flatten exactly and some lose data. When repeated elements are collapsed into one cell, the panel says so and gives the count; when the row element was guessed, it names what it picked. Most converters hand you a file and leave you to notice.

CSV is a rectangle and XML is a tree

RFC 4180 puts the constraint in one line: "Each line should contain the same number of fields throughout the file." XML has no such rule, and that mismatch is the whole difficulty. The conversion is faithful only when the XML is already tabular: one repeating record whose children are single-valued leaves.

When a record contains a repeated child, an <order> holding three <line> elements, a converter has three options: explode into one row per line and duplicate the parent columns, collapse the repeats into one cell, or invent line.1.sku and line.2.sku columns whose count depends on the widest record. All three lose something, there is no fourth, and this tool takes the second and reports it.

  • Flattens exactly: one repeating element under the root, leaf children, attributes on the record or its leaves.
  • Flattens with loss: a record containing a repeated child. Values are joined and the count is reported.
  • Does not flatten: mixed content, heterogeneous siblings, or a document whose nesting is the point. Use JSON.

How the row element and the columns are chosen

Leave the Row element box empty and the most frequent element child of the root becomes the row, which is right for almost all XML-to-CSV input. The decision is printed rather than hidden: "Rows were taken from the 2 <book> elements under <catalog>". If the guess is wrong, type the name yourself.

One limit is worth knowing before you paste. The row element must be a direct child of the root, so a document that wraps its list one level deeper, <catalog><books><book/><book/></books></catalog>, gives a single row for <books>. Strip that wrapper, or convert to JSON where the nesting survives.

Columns are the union of every path across every row, in first-seen order, so a field only some records carry still gets a column and the rest get an empty cell. Attributes land at the depth they occur, so a currency attribute on price becomes price.@currency rather than colliding with price. Namespace prefixes are kept verbatim, entities and CDATA are resolved, leaf text is trimmed.

<catalog xmlns:dc="http://purl.org/dc/elements/1.1/">
  <book id="bk101" available="true">
    <dc:title>XML Developer's Guide</dc:title>
    <price currency="GBP">44.95</price>
  </book>
</catalog>

@id,@available,dc:title,price.@currency,price
bk101,true,XML Developer's Guide,GBP,44.95
The sample document and the columns it produces. Fields are quoted only when RFC 4180 requires it.

Repeated elements, and exactly what is lost

When a record holds the same leaf element more than once, the values are joined with a pipe into one cell and counted. The cell is still escaped correctly, so a comma inside one of those values does not break the columns. What you lose is the boundary: a value that already contains a pipe becomes ambiguous, and the separator is not configurable.

One case the join does not cover: when the repeated element has children of its own, each occurrence writes into the same dotted columns, so the last one wins and the earlier ones are overwritten without a note. Image extensions inside a sitemap <url>, and line items with several fields each, both behave this way. Pull those out with XPath, or convert to JSON.

Sitemaps, and the SEO use for this

A sitemap is a record list, so it converts with no configuration: the root is <urlset>, the repeated child is <url>, and you get loc, lastmod, changefreq and priority as columns, one row per URL. That is enough to sort by modification date, count against the 50,000 URL limit, or paste the loc column beside a crawl export to find the pages in one and not the other. A sitemap index converts the same way.

Two facts to carry into the spreadsheet. Google states that it ignores priority and changefreq, so those columns audit what your CMS emits and nothing else. And Google uses lastmod only when it is "consistently and verifiably accurate", so a column where every row shares a timestamp, or holds a future date, is one Google is likely to discard. For rule checking rather than extraction, use the sitemap validator instead, which enforces the caps, the changefreq vocabulary and the W3C Datetime format.

Doing this in code

The same flattening in the languages that consume XML. Reading XML is the dangerous direction, so each sample uses the safe parser configuration: external entities off, DTD loading off, no network. The CSV writing is explicit, because escaping is where hand-rolled exports usually break.

// Browser or Deno. DOMParser never resolves external entities, so XXE is
// not reachable here. In Node use @xmldom/xmldom, which also does not.
function xmlToCsv(source, rowTag) {
  const doc = new DOMParser().parseFromString(source, 'application/xml');
  const error = doc.querySelector('parsererror');
  if (error) throw new Error(error.textContent.trim());

  const root = doc.documentElement;
  const rows = [...root.children].filter((el) => el.tagName === rowTag);
  if (rows.length === 0) throw new Error('No <' + rowTag + '> under <' + root.tagName + '>');

  const columns = [];
  const records = rows.map((row) => {
    const record = {};
    const put = (key, value) => {
      if (!columns.includes(key)) columns.push(key);
      // Repeated leaves are joined. That is lossy; count it in real code.
      record[key] = record[key] === undefined ? value : record[key] + '|' + value;
    };
    const walk = (el, prefix) => {
      for (const a of el.attributes) {
        put(prefix ? prefix + '.@' + a.name : '@' + a.name, a.value);
      }
      const kids = [...el.children];
      if (kids.length === 0) {
        put(prefix || el.tagName, el.textContent.trim());
        return;
      }
      for (const k of kids) walk(k, prefix ? prefix + '.' + k.tagName : k.tagName);
    };
    walk(row, '');
    return record;
  });

  // RFC 4180: quote a field containing a comma, a quote or a line break,
  // and double any quote inside it.
  const cell = (v) => (/[",\r\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v);
  const line = (values) => values.map((v) => cell(v ?? '')).join(',');

  return [line(columns), ...records.map((r) => line(columns.map((c) => r[c])))].join('\r\n');
}
# pip install defusedxml
# The standard library parser is not safe against entity expansion.
# defusedxml is a drop-in replacement that closes that and external entities.
import csv
import sys
from defusedxml.ElementTree import parse


def flatten(el, prefix, record, columns):
    for name, value in el.attrib.items():
        key = f"{prefix}.@{name}" if prefix else f"@{name}"
        if key not in columns:
            columns.append(key)
        record[key] = value

    children = list(el)
    if not children:
        key = prefix or el.tag
        if key not in columns:
            columns.append(key)
        record[key] = (el.text or "").strip()
        return

    for child in children:
        # ElementTree reports namespaced names in Clark notation,
        # '{http://purl.org/dc/elements/1.1/}title', not 'dc:title'.
        tag = child.tag.split("}")[-1]
        key = f"{prefix}.{tag}" if prefix else tag
        if key in record:  # repeated leaf: join, and note the loss
            record[key] += "|" + (child.text or "").strip()
        else:
            flatten(child, key, record, columns)


def xml_to_csv(path, row_tag, out):
    root = parse(path).getroot()
    columns, records = [], []
    for row in root.findall(row_tag):
        record = {}
        flatten(row, "", record, columns)
        records.append(record)
    writer = csv.DictWriter(out, fieldnames=columns, restval="", extrasaction="ignore")
    writer.writeheader()
    writer.writerows(records)


# newline="" or csv writes \r\r\n on Windows.
# utf-8-sig writes the BOM Excel needs to read the file as UTF-8.
with open("out.csv", "w", newline="", encoding="utf-8-sig") as out:
    xml_to_csv(sys.argv[1], sys.argv[2], out)
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import javax.xml.XMLConstants;
import javax.xml.parsers.*;
import org.w3c.dom.*;

public class XmlToCsv {

    static DocumentBuilder secureBuilder() throws Exception {
        DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();
        // None of this is the default. Without it a document can read files
        // off the machine running the conversion.
        f.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
        f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
        f.setFeature("http://xml.org/sax/features/external-general-entities", false);
        f.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
        f.setXIncludeAware(false);
        f.setExpandEntityReferences(false);
        return f.newDocumentBuilder();
    }

    static void flatten(Element el, String prefix, Map<String, String> rec, List<String> cols) {
        NamedNodeMap attrs = el.getAttributes();
        for (int i = 0; i < attrs.getLength(); i++) {
            Node a = attrs.item(i);
            String key = prefix.isEmpty() ? "@" + a.getNodeName() : prefix + ".@" + a.getNodeName();
            if (!cols.contains(key)) cols.add(key);
            rec.put(key, a.getNodeValue());
        }

        List<Element> kids = new ArrayList<>();
        NodeList children = el.getChildNodes();
        for (int i = 0; i < children.getLength(); i++) {
            if (children.item(i) instanceof Element e) kids.add(e);
        }

        if (kids.isEmpty()) {
            String key = prefix.isEmpty() ? el.getNodeName() : prefix;
            if (!cols.contains(key)) cols.add(key);
            rec.put(key, el.getTextContent().trim());
            return;
        }
        for (Element k : kids) {
            String key = prefix.isEmpty() ? k.getNodeName() : prefix + "." + k.getNodeName();
            if (rec.containsKey(key)) {
                rec.put(key, rec.get(key) + "|" + k.getTextContent().trim());
            } else {
                flatten(k, key, rec, cols);
            }
        }
    }

    static String cell(String v) {
        if (v == null) return "";
        boolean needsQuotes = v.indexOf(',') >= 0 || v.indexOf('"') >= 0
                || v.indexOf('\r') >= 0 || v.indexOf('\n') >= 0;
        return needsQuotes ? '"' + v.replace("\"", "\"\"") + '"' : v;
    }

    public static void main(String[] args) throws Exception {
        Document doc = secureBuilder().parse(new File(args[0]));
        List<String> cols = new ArrayList<>();
        List<Map<String, String>> recs = new ArrayList<>();

        NodeList rows = doc.getDocumentElement().getElementsByTagName(args[1]);
        for (int i = 0; i < rows.getLength(); i++) {
            Map<String, String> rec = new LinkedHashMap<>();
            flatten((Element) rows.item(i), "", rec, cols);
            recs.add(rec);
        }

        try (PrintWriter out = new PrintWriter(new OutputStreamWriter(
                new FileOutputStream("out.csv"), StandardCharsets.UTF_8))) {
            out.print(String.join(",", cols.stream().map(XmlToCsv::cell).toList()) + "\r\n");
            for (Map<String, String> rec : recs) {
                out.print(String.join(",",
                        cols.stream().map(c -> cell(rec.get(c))).toList()) + "\r\n");
            }
        }
    }
}
using System.Text;
using System.Xml;
using System.Xml.Linq;

// A null XmlResolver means an external DTD or entity is never fetched.
var settings = new XmlReaderSettings
{
    DtdProcessing = DtdProcessing.Prohibit,
    XmlResolver = null,
    MaxCharactersFromEntities = 1024 * 1024,
};

using var reader = XmlReader.Create(args[0], settings);
var doc = XDocument.Load(reader);
var rowName = args[1];

var columns = new List<string>();
var records = new List<Dictionary<string, string>>();

void Flatten(XElement el, string prefix, Dictionary<string, string> rec)
{
    foreach (var a in el.Attributes())
    {
        if (a.IsNamespaceDeclaration) continue;   // xmlns is not data
        var key = prefix.Length == 0 ? "@" + a.Name.LocalName : prefix + ".@" + a.Name.LocalName;
        if (!columns.Contains(key)) columns.Add(key);
        rec[key] = a.Value;
    }

    var kids = el.Elements().ToList();
    if (kids.Count == 0)
    {
        var key = prefix.Length == 0 ? el.Name.LocalName : prefix;
        if (!columns.Contains(key)) columns.Add(key);
        rec[key] = el.Value.Trim();
        return;
    }

    foreach (var k in kids)
    {
        var key = prefix.Length == 0 ? k.Name.LocalName : prefix + "." + k.Name.LocalName;
        if (rec.ContainsKey(key)) rec[key] += "|" + k.Value.Trim();
        else Flatten(k, key, rec);
    }
}

foreach (var row in doc.Root!.Elements().Where(e => e.Name.LocalName == rowName))
{
    var rec = new Dictionary<string, string>();
    Flatten(row, "", rec);
    records.Add(rec);
}

static string Cell(string? v)
{
    if (v is null) return "";
    return v.IndexOfAny(new[] { ',', '"', '\r', '\n' }) >= 0
        ? "\"" + v.Replace("\"", "\"\"") + "\""
        : v;
}

// UTF8Encoding(true) writes a BOM, which is what makes Excel on Windows
// read the file as UTF-8 rather than the system code page.
using var writer = new StreamWriter("out.csv", false, new UTF8Encoding(true));
writer.WriteLine(string.Join(",", columns.Select(Cell)));
foreach (var rec in records)
{
    writer.WriteLine(string.Join(",", columns.Select(c => Cell(rec.GetValueOrDefault(c)))));
}
<?php
// LIBXML_NONET blocks network access during the parse. PHP 8 does not load
// external entities by default; passing the flag keeps this correct on PHP 7
// and documents the intent.
libxml_use_internal_errors(true);

$doc = new DOMDocument();
if (!$doc->load($argv[1], LIBXML_NONET)) {
    foreach (libxml_get_errors() as $e) {
        fprintf(STDERR, "line %d: %s", $e->line, $e->message);
    }
    exit(1);
}

$rowName = $argv[2];
$columns = [];
$records = [];

$flatten = function (DOMElement $el, string $prefix, array &$rec) use (&$flatten, &$columns) {
    foreach ($el->attributes as $a) {
        $key = $prefix === '' ? '@' . $a->name : $prefix . '.@' . $a->name;
        if (!in_array($key, $columns, true)) $columns[] = $key;
        $rec[$key] = $a->value;
    }

    $kids = [];
    foreach ($el->childNodes as $n) {
        if ($n instanceof DOMElement) $kids[] = $n;
    }

    if (!$kids) {
        $key = $prefix === '' ? $el->nodeName : $prefix;
        if (!in_array($key, $columns, true)) $columns[] = $key;
        $rec[$key] = trim($el->textContent);
        return;
    }

    foreach ($kids as $k) {
        $key = $prefix === '' ? $k->nodeName : $prefix . '.' . $k->nodeName;
        if (array_key_exists($key, $rec)) {
            $rec[$key] .= '|' . trim($k->textContent);
        } else {
            $flatten($k, $key, $rec);
        }
    }
};

foreach ($doc->documentElement->childNodes as $node) {
    if ($node instanceof DOMElement && $node->nodeName === $rowName) {
        $rec = [];
        $flatten($node, '', $rec);
        $records[] = $rec;
    }
}

$out = fopen('out.csv', 'w');
fwrite($out, "\xEF\xBB\xBF");        // BOM, for Excel
fputcsv($out, $columns);              // fputcsv applies RFC 4180 quoting
foreach ($records as $rec) {
    fputcsv($out, array_map(fn($c) => $rec[$c] ?? '', $columns));
}
fclose($out);
# xmlstarlet is the quickest route for a one-off extraction.
# --net=false stops it fetching a DTD or an external entity. Not the default.
xmlstarlet sel --net=false -t \
  -o 'id,title,price' -n \
  -m '/catalog/book' \
    -v '@id' -o ',' \
    -v 'title' -o ',' \
    -v 'price' -n \
  catalog.xml > out.csv

# It does no CSV quoting at all. A title containing a comma, a quote or a
# newline breaks the column alignment silently, so either be certain the
# data is clean or re-quote the output:
xmlstarlet sel --net=false -t -m '/catalog/book' \
  -v 'concat(@id,",",title)' -n catalog.xml | csvformat -   # csvkit

# For files too large to hold in memory, iterate instead of loading:
#   python -c 'from xml.etree.ElementTree import iterparse; ...'

Every sample disables something in the parser. The unsafe behaviour is the default in Java and in Python's standard library, and conversion is exactly the context where the file arrived from a supplier, a customer or an inbox rather than from you.

Common questions

Is my XML uploaded when I convert it?

No. The scanner, the flattener and the CSV writer are JavaScript running in a Web Worker in this tab, and there is no back end to send anything to. Open your network panel and convert something: the page assets load once, then nothing.

That matters more for conversion than for validation, because of what these files are. Nobody converts a tutorial snippet to CSV. People convert customer exports, order histories and supplier price lists. Your input is kept in this browser's localStorage so a refresh does not lose it, and Clear removes it.

Why did I get one row instead of hundreds?

Almost always because the list sits one level below the root. The row element is chosen from the direct children of the root, so <catalog><books><book/><book/></books></catalog> sees one <books> child, gives one row, and flattens every <book> into the same columns with the last one winning.

Remove the wrapper so the repeated element is a direct child, or convert to JSON. Naming <book> in the Row element box does not help: it reports that no <book> was found under the root, which is true.

What does the pipe in that cell mean?

CSV cannot express a field that occurs more than once, so several copies of the same leaf element are joined with a pipe and the panel reports how often that happened. The cell is escaped properly, so commas inside those values do not break the columns. The loss is the boundary: a value that already contains a pipe is now ambiguous.

When the repeated element has children of its own the join does not apply. Each occurrence writes into the same dotted columns and the last overwrites the rest. Extract those with XPath, or use JSON, where repeats become an array.

Will the result open properly in Excel?

Usually, with two caveats that get blamed on the converter. Excel on Windows reads a plain UTF-8 file as the system code page unless it starts with a byte order mark, which turns accented names into mojibake: import through Data then From Text/CSV and set UTF-8 rather than double-clicking.

Excel also reformats values it recognises. A product code of 0012 loses its zeros, 1-2 becomes a date, a long identifier becomes scientific notation. Mark those columns as Text on import, and if your locale splits on semicolons, switch the Delimiter control above.

How large a file can I convert?

Up to 20 million characters, roughly 20 MB, with no account, no queue and no size tier, because there is no server to meter one. The most visible free XML tool here caps input at 512 KB and asks you to confirm your data is stored on its servers.

The limit is memory rather than policy: everything is held in this tab. Below it the cost is linear, 1 MB in about 110 milliseconds and 10 MB in a little over a second. Above it, stream locally with iterparse, SAX or xmlstarlet.

Should I convert to CSV or to JSON?

CSV when the destination is a spreadsheet, a bulk import expecting columns, or a person who will sort and filter. JSON when the data has structure worth keeping.

The test is quick: look at one record and ask whether every field appears exactly once and holds a single value. If yes, CSV is faithful. If any repeats or carries sub-fields, you will be rebuilding that structure later out of delimiters inside cells.

Related tools

Background reading