JSON to XML Converter

Convert to XML, and see what had to be renamed and why.

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 JSON above and well-formed XML appears beside it, with a declaration, real indentation and every special character escaped. If the JSON does not parse you get the parser's own message rather than a blank pane, and if a key had to be renamed to become a legal XML element name the tool says which and what it became.

You need this when something downstream only speaks XML: a SOAP endpoint, a legacy ERP import, an XSD-validated exchange format, a fixture for a service you are mocking. It is the less glamorous half of the pair and the half with sharper edges, because XML imposes constraints JSON does not and those constraints have to be resolved somewhere.

Everything runs in this tab. Nothing is uploaded, which is worth stating because the JSON people paste into converters is usually a captured API response, and captured API responses contain tokens, account numbers and customer records.

XML needs exactly one root. JSON does not.

RFC 8259 permits any value at the top level of a JSON document: an object, an array, a string, a number, true, false or null. XML 1.0 requires exactly one root element containing everything else, so the mismatch is resolved on every conversion.

An object with exactly one key already has a natural root, so that key becomes the root element and nothing is invented. {"order": {...}} gives <order>...</order> with no wrapper, which is the common case because it is the shape converting XML to JSON gives back. Anything else is wrapped, and the notes panel says so:

  • An object with two or more top-level keys is wrapped in a single element, named root by default and editable in the control row.
  • A top-level array is wrapped twice, because an array has no element name of its own: each member becomes <item> inside <root>.
  • A top-level scalar becomes the text of the root element, so the JSON document 42 gives <root>42</root>.
  • A top-level null gives an empty root element, <root/>.

Arrays repeat the element name. They do not get a wrapper.

This is the decision most converters get backwards. {"line": ["a", "b"]} becomes two sibling <line> elements, not a <line> element holding two <item> children. Repetition is how XML expresses a list; it is the reason the XML-to-JSON direction has the singleton problem at all. Inventing a wrapper produces XML no existing schema would accept, and it would not round-trip.

Two consequences follow. An empty array produces nothing at all, so the key vanishes: zero repetitions of an element is zero elements. And an array of arrays flattens, because the inner array has no name distinct from the outer one, so [[1,2],[3]] under the key a gives three <a> elements. If either matters, restructure the JSON first.

{
  "order": {
    "@_id": "00042",
    "line": [ "Widget", "Gasket" ],
    "note": null,
    "meta": {},
    "tags": []
  }
}

<?xml version="1.0" encoding="UTF-8"?>
<order id="00042">
  <line>Widget</line>
  <line>Gasket</line>
  <note/>
  <meta/>
</order>
Arrays, null, empty objects and empty arrays.

JSON keys are frequently not legal XML names

XML 1.0 section 2.3 defines a Name as a NameStartChar followed by NameChars. A NameStartChar is a letter, an underscore or a colon; it is not a digit, a space, an ampersand or a dollar sign. A JSON key has no such restriction, so "2024 total", "user@email" and "$ref" are ordinary keys and none is a legal element name.

The .NET and XSD world escapes, turning a space into _x0020_, which is exact and unreadable. This tool sanitises and reports instead: illegal characters are replaced one for one rather than removed, and a name still starting with a digit is prefixed. That is the point, because removal collapses distinct keys into one name and replacement does not. Every rename appears in the notes panel.

  • "2024 total" becomes _2024_total: the space is replaced, then the leading digit forces a prefix.
  • "2024-total" becomes _2024-total: a hyphen is already legal, so only the leading digit needs the prefix. The two stay distinct, which is exactly what stripping would have cost you.
  • "user@email" becomes user_email, "$ref" becomes _ref, and an empty key becomes a bare underscore.
  • A key that is already legal passes through untouched, including one with a colon: "soap:Body" stays "soap:Body". That gives a prefixed element with no xmlns declaration, which is well-formed but not namespace-well-formed.

Attributes, text, and what JSON loses first

Keys beginning with @_ become attributes on the enclosing element, and a key called #text supplies the text content. Both match the XML-to-JSON direction, so output from that page converts straight back. Attribute values are escaped harder than text: as well as &, < and ", the writer escapes tab, newline and carriage return as numeric character references, because XML 1.0 section 3.3.3 normalises literal whitespace in attribute values to spaces on reparse.

Two losses happen inside JSON itself, before this tool is involved, and they look like conversion bugs. JSON numbers are IEEE 754 doubles, so a nineteen-digit identifier written as a bare number has already lost its low digits by the time the text is parsed. And duplicate keys are resolved by the parser, last one winning. There is a JavaScript quirk too: keys that look like array indices are enumerated first and in ascending numeric order, so an object mixing "2", "10" and "name" will not emit its elements in the order you wrote them.

null and the empty object both produce <x/>, so the two are indistinguishable and both come back as the empty string. If you need the distinction, xsi:nil="true" is the only standards-backed way to say "present but null", and it needs the xsi namespace declared on an ancestor.

Doing this in code

The same conversion in the four languages that consume XML most, plus PHP and a shell one-liner. The security flags matter on the way back: JSON parsing is not the risk, but code that converts JSON to XML almost always re-parses that XML somewhere, and the defaults in Java and .NET will resolve a DOCTYPE if one turns up.

import { XMLBuilder } from 'fast-xml-parser';

const builder = new XMLBuilder({
  ignoreAttributes: false,        // default is true: @_ keys would be dropped
  attributeNamePrefix: '@_',
  textNodeName: '#text',
  format: true,
  indentBy: '  ',
  suppressEmptyNode: true,        // write <note/> rather than <note></note>
  processEntities: true,          // escape &, < and " in values
});

const xml = '<?xml version="1.0" encoding="UTF-8"?>\n' + builder.build(data);

// XMLBuilder does not sanitise keys. A key of "2024 total" is written
// verbatim and produces XML that will not parse, so check before building:
const illegal = Object.keys(flatten(data))
  .filter((k) => !/^[A-Za-z_][\w.\-]*(:[A-Za-z_][\w.\-]*)?$/.test(k));
if (illegal.length) throw new Error('Illegal XML names: ' + illegal.join(', '));
import json
import re
import xmltodict


def legal_name(key):
    """Replace illegal characters rather than stripping them, so that
    distinct keys stay distinct. Prefix a leading digit."""
    name = re.sub(r'[^\w.\-:]', '_', key, flags=re.UNICODE)
    return name if re.match(r'^[A-Za-z_:]', name) else '_' + name


def sanitise(node):
    if isinstance(node, dict):
        return {legal_name(k): sanitise(v) for k, v in node.items()}
    if isinstance(node, list):
        return [sanitise(v) for v in node]
    return node


data = json.loads(json_source)
if not isinstance(data, dict) or len(data) != 1:
    data = {'root': data}          # xmltodict.unparse requires a single root

print(xmltodict.unparse(
    sanitise(data),
    pretty=True, indent='  ',
    attr_prefix='@_', cdata_key='#text',
    full_document=True,            # emit the <?xml ...?> declaration
))

# xmltodict raises ValueError("Document must have exactly one root.") rather
# than guessing, which is correct behaviour and the reason for the wrap.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;

JsonNode tree = new ObjectMapper().readTree(jsonSource);

XmlMapper xml = new XmlMapper();
xml.enable(SerializationFeature.INDENT_OUTPUT);

// JSON has no root name and Jackson will not invent one, so supply it.
String out = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
    + xml.writer().withRootName("root").writeValueAsString(tree);

// Two things Jackson will not do for you:
//  1. It does not sanitise names. A key with a space throws
//     IllegalArgumentException at write time, which is at least loud.
//  2. It writes every value as a child element. There is no attribute
//     convention on a JsonNode, so @_ keys become elements unless you bind
//     to a class annotated with @JacksonXmlProperty(isAttribute = true).
using System.Xml;
using Newtonsoft.Json;

// The second argument is the root element name, used when the JSON does not
// already have exactly one top-level property. Without it, multi-key JSON
// throws JsonSerializationException rather than producing invalid XML.
XmlDocument? document = JsonConvert.DeserializeXmlNode(jsonSource, "root");

if (document is null) throw new InvalidOperationException("Empty JSON.");

var settings = new XmlWriterSettings { Indent = true, IndentChars = "  " };
using var writer = XmlWriter.Create(Console.Out, settings);
document.Save(writer);

// Json.NET uses "@" for attributes and "#text" for text, so retarget the
// keys if your JSON came from a converter using "@_". It does not sanitise
// names either: a property called "2024 total" throws XmlException("The ''
// character, hexadecimal value 0x20, cannot be included in a name").
<?php
$data = json_decode($source, true, 512, JSON_THROW_ON_ERROR);

function legal_name(string $key): string {
    $name = preg_replace('/[^\w.\-:]/u', '_', $key);
    return preg_match('/^[A-Za-z_:]/', $name) ? $name : '_' . $name;
}

function write_node(XMLWriter $w, string $name, mixed $value): void {
    if (is_array($value) && array_is_list($value)) {
        foreach ($value as $v) write_node($w, $name, $v);   // repeat, no wrapper
        return;
    }
    $w->startElement(legal_name($name));
    if (is_array($value)) {
        foreach ($value as $k => $v) {
            if (str_starts_with((string) $k, '@_')) {
                $w->writeAttribute(legal_name(substr((string) $k, 2)), (string) $v);
            } elseif ($k === '#text') {
                $w->text((string) $v);
            } else {
                write_node($w, (string) $k, $v);
            }
        }
    } elseif ($value !== null) {
        $w->text(is_bool($value) ? ($value ? 'true' : 'false') : (string) $value);
    }
    $w->endElement();
}

$single = count($data) === 1;
$w = new XMLWriter();
$w->openMemory();
$w->setIndent(true);
$w->setIndentString('  ');
$w->startDocument('1.0', 'UTF-8');
write_node($w, $single ? (string) array_key_first($data) : 'root',
               $single ? reset($data) : $data);
$w->endDocument();
echo $w->outputMemory();
# yq v4 (Mike Farah). JSON is a subset of YAML, so -p=json works directly.
yq -p=json -o=xml '.' payload.json

# Set the root and the key conventions to match this page:
yq -p=json -o=xml \
   --xml-attribute-prefix='@_' \
   --xml-content-name='#text' \
   '{"root": .}' payload.json

# yq writes no XML declaration, so prepend one if a consumer expects it, and
# check the result: yq does not sanitise element names.
{ echo '<?xml version="1.0" encoding="UTF-8"?>'
  yq -p=json -o=xml '{"root": .}' payload.json; } | xmllint --noout --nonet -

Note what none of these libraries do: sanitise a key into a legal XML name. Jackson, Json.NET and XMLBuilder either throw or emit XML that will not parse, and yq emits it silently. If your JSON keys come from user input, a database column list or a spreadsheet header row, the sanitising step is yours to write, and replacing characters rather than deleting them is what keeps two similar keys from becoming one element.

Common questions

Does my JSON leave the browser?

No. The JSON parser, the name sanitiser and the XML writer are all JavaScript running in this tab, and there is no server-side component for them to talk to. Open the Network panel and convert something; nothing is requested.

This is the direction where it matters most. JSON pasted into a converter is usually a response captured from a live API while debugging, complete with an access token or a full customer record. Several tools ranking for this search post that payload to a server, and one publishes saved documents at a guessable URL.

Why is my JSON wrapped in a <root> element?

Because XML allows exactly one root element and your JSON had more than one top-level key, or was an array, or was a bare scalar. There is no way to write two sibling roots in XML.

An object with exactly one key is left alone: that key becomes the root and no wrapper is added, so {"order": {...}} gives <order> while adding a second top-level key gives <root>. The wrapper name is editable in the control row. If the XML is going somewhere that validates, set it to whatever the schema expects.

How are JSON arrays converted?

By repeating the element name once per member, with no wrapper. {"line": ["a", "b"]} produces two <line> elements side by side. That is how XML represents a list, and it is what makes the output round-trip. Some converters instead produce <line><item>a</item><item>b</item></line>, which reads more like the JSON and fails validation against any schema written for real XML.

Two edge cases follow. An empty array emits nothing, so the key disappears, and an array nested directly inside another flattens, because the inner one has no name of its own.

What happens to keys that are not valid XML element names?

They are renamed, and every rename is listed in the notes panel beside the output. Illegal characters are replaced one for one with an underscore, and a name that still begins with a digit gets an underscore in front.

Replacing rather than removing is deliberate: stripping would turn "2024 total" and "2024total" into the same element and merge two distinct fields. It is not a perfect injection, though: "first name" and "first_name" both become first_name, because the underscore was already legal in the second.

How do I get attributes instead of child elements?

Prefix the key with @_. {"user": {"@_id": "7", "name": "Alice"}} produces <user id="7"><name>Alice</name></user>. The prefix is editable above the editor; clear it and nothing is ever written as an attribute.

Put only scalars there. The value is stringified, so an object under an @_ key becomes the useless text [object Object]. One thing this writer does that most do not: tabs, newlines and carriage returns inside an attribute value are written as numeric character references, so a multi-line value survives a reparse instead of being flattened to spaces.

Can I convert back to JSON and get what I started with?

For most documents, yes, if you use the XML to JSON page with the same @_ and #text settings. That pairing is why those defaults were chosen.

Four things do not survive. null and {} both become <x/> and come back as the empty string. An empty array disappears entirely. JSON number types are gone, because XML has no types, so 42 comes back as "42" unless you switch on coercion. And key order is not meaningful in either format. If exact round-tripping is a requirement, keep the XML as the source of truth and read from it with XPath.

Related tools

Background reading