YAML to XML Converter

Convert YAML to XML, entirely in your browser.

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 YAML above and indented, well-formed XML appears beside it, with a declaration and every special character escaped. If the YAML does not parse you get the parser's own first line of complaint rather than a blank pane, and if a key had to be renamed to become a legal XML element name the tool lists what changed.

This is the direction you need when something old has to read something new: an integration that only accepts XML, a SOAP endpoint, an XSD-validated exchange format, a fixture for a service configured in YAML. It has a surprising number of sharp edges, and most come from YAML rather than from XML.

Parsing is done by js-yaml, loaded only when you open this page rather than on every page of the site. Everything runs in this tab and nothing is uploaded, which matters because YAML is where configuration lives and configuration is where credentials live.

How the three YAML node kinds map

YAML has exactly three kinds of node and each has one XML counterpart. A mapping becomes a set of child elements, one per key, with the key as the element name. A sequence repeats its parent element name once per member, with no wrapper, because repetition is how XML expresses a list. A scalar becomes the text content of its element.

On top of that sits the root problem. YAML permits any node at the top of a document; XML requires exactly one root. A mapping with one key already has a natural root, so that key becomes the root element. A mapping with two or more keys, a top-level sequence or a bare scalar is wrapped in a single element, named root by default and editable in the control row.

Two consequences of the sequence rule are worth knowing. An empty sequence produces nothing at all, so the key disappears: zero repetitions of an element is zero elements. And a sequence nested directly inside another sequence flattens, because the inner one has no name of its own to use.

order:
  id: '00042'
  line:
    - Widget
    - Gasket
  note: null
  tags: []

<?xml version="1.0" encoding="UTF-8"?>
<order>
  <id>00042</id>
  <line>Widget</line>
  <line>Gasket</line>
  <note/>
</order>
A mapping, a sequence, a null and an empty sequence.

YAML has decided your types before XML sees them

This is the most important thing on the page and it is not a property of this converter. YAML resolves a plain scalar to a type based on how it is spelled, inside the parser. By the time a value reaches the XML writer it is already a number, a boolean, a date or a string, and XML has no type system with which to recover the distinction.

js-yaml implements the YAML 1.2 core schema plus the timestamp type, which produces the following. You can check every line of it by pasting the value in:

  • true and false are booleans and are written as the text true and false. yes and no stay strings here, but a YAML 1.1 parser such as PyYAML or Ansible reads no as false, so the same file converted with different tooling produces different XML.
  • Leading zeros are gone before conversion: 01730 resolves to the number 1730, and there is nothing the XML writer can do about it. Write '01730'.
  • Alternative bases are resolved too, so 0x1F becomes 31 and is written as <hex>31</hex>. Hex colour codes and hardware identifiers need quoting.
  • A YAML integer becomes a double in the browser, so a nineteen-digit identifier has already lost its low digits before the writer is involved. Quote identifiers, always.
  • Dates resolve to timestamps, and a timestamp has no text representation the writer can produce, so 2024-01-05 comes out as an empty <when/>. Quote it and it is written as text.

YAML keys are frequently not legal XML names

XML 1.0 section 2.3 says an element name starts with a letter, an underscore or a colon and continues with those plus digits, hyphens and full stops. A YAML key has no such restriction: "2024 total", "user@email" and the empty string are all ordinary keys and none can be an element name.

Each is renamed rather than rejected, and every rename is reported. Illegal characters are replaced one for one with an underscore, and a name still beginning with a digit gets an underscore in front. Replacing rather than stripping is deliberate: stripping would turn "2024 total" and "2024total" into the same element and merge two distinct fields. So "2024 total" becomes _2024_total, "2024-total" becomes _2024-total because a hyphen is already legal, and "user@email" becomes user_email. It is not airtight: "first name" and "first_name" both land on first_name, so rename keys that differ only in punctuation.

YAML also allows keys that are not strings: 2024 is an integer, true is a boolean, and the explicit key syntax allows a whole sequence as a key. All are stringified before becoming element names. One quirk: keys that look like array indices are enumerated first and in ascending numeric order, so a mapping mixing 2, 10 and name will not emit its elements in the order you wrote them.

Multi-document streams, anchors and merge keys

A YAML stream can hold several documents separated by three hyphens, and Kubernetes manifests routinely do. XML has exactly one root, so all of them are read and wrapped: a single <documents> element with one <document> child per YAML document, and a note saying how many were found. Most converters silently truncate to the first, which you discover in production when two thirds of a manifest quietly disappears.

Anchors, aliases and merge keys are resolved by the parser and gone by the time the XML is written. What you get is the fully expanded result, which is correct and can be considerably larger than the input: one base block aliased into forty services produces forty copies. That expansion happens in this tab's memory, so heavy aliasing can be slow, and the size cap applies to the output as well as the source.

Comments are not preserved, because they are not part of the YAML data model and the parser never hands them over. An empty stream, or one containing only comments, is reported as empty rather than converted into an empty root element.

Doing this in code

Two steps in every language: load the YAML with a safe loader, then write XML with something that escapes properly. The safety flag is on the YAML side rather than the XML side here. Several YAML libraries will, by default or by a single tag in the document, instantiate arbitrary classes from the file, which is remote code execution dressed as configuration.

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

// load() uses the default schema, which constructs no JavaScript types.
// Do not swap in js-yaml's extended schema for untrusted input.
const docs = [];
yaml.loadAll(yamlSource, (d) => docs.push(d));

if (docs.length === 0) throw new Error('The YAML document is empty.');
// XML has one root; a multi-document stream needs wrapping, not truncating.
let data = docs.length > 1 ? { documents: { document: docs } } : docs[0];

if (data === null || typeof data !== 'object' || Array.isArray(data)
    || Object.keys(data).length !== 1) {
  data = { root: data };
}

const builder = new XMLBuilder({
  ignoreAttributes: false,
  attributeNamePrefix: '@_',
  textNodeName: '#text',
  format: true,
  indentBy: '  ',
  suppressEmptyNode: true,
});

console.log('<?xml version="1.0" encoding="UTF-8"?>');
console.log(builder.build(data));

// XMLBuilder does not sanitise names. A YAML key of "2024 total" is written
// verbatim and the result will not parse, so validate before you ship it.
import re
import yaml
import xmltodict


def legal_name(key):
    """Replace illegal characters rather than stripping them, so distinct
    keys stay distinct. Prefix a leading digit."""
    name = re.sub(r'[^\w.\-:]', '_', str(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]
    if isinstance(node, bool):
        return 'true' if node else 'false'
    return node if node is None else str(node)


# safe_load, never load: yaml.load with the default Loader will construct
# arbitrary Python objects from !!python tags in the document.
docs = [d for d in yaml.safe_load_all(yaml_source) if d is not None]
if not docs:
    raise SystemExit('The YAML document is empty.')

data = {'documents': {'document': docs}} if len(docs) > 1 else docs[0]
if not isinstance(data, dict) or len(data) != 1:
    data = {'root': data}

print(xmltodict.unparse(sanitise(data), pretty=True, indent='  ',
                        full_document=True))

# PyYAML applies the YAML 1.1 resolver, so an unquoted no is False here and
# a string in js-yaml. Quote anything whose type you care about.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;

// Jackson's YAML module wraps SnakeYAML but binds only to JsonNode and to
// classes you name, so the SnakeYAML deserialisation gadget problem
// (CVE-2022-1471, the default Constructor instantiating arbitrary types)
// is not reachable through this API. Using SnakeYAML directly, construct it
// as: new Yaml(new SafeConstructor(new LoaderOptions()))
JsonNode tree = new YAMLMapper().readTree(yamlSource);

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

String out = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
    + xml.writer().withRootName("root").writeValueAsString(tree);

// readTree reads the first document only. For a multi-document stream use
// new YAMLMapper().readerFor(JsonNode.class).readValues(yamlSource)
// and wrap the results yourself.
using Newtonsoft.Json;
using YamlDotNet.Serialization;

// YamlDotNet's Deserializer binds only to types you name and does not
// resolve arbitrary .NET types from tags in the document.
var yaml = new DeserializerBuilder().Build();
object? tree = yaml.Deserialize<object>(new StringReader(yamlSource));

if (tree is null) throw new InvalidOperationException("The YAML is empty.");

// Round-trip through JSON so Json.NET can do the XML writing, including the
// escaping. The second argument names the root, which YAML does not supply
// and XML requires.
string json = JsonConvert.SerializeObject(tree);
var document = JsonConvert.DeserializeXmlNode(json, "root")
    ?? throw new InvalidOperationException("Nothing to write.");

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

// Json.NET will not sanitise names: a YAML key of "2024 total" throws
// XmlException when the node is created. Rewrite keys before this point.
<?php
use Symfony\Component\Yaml\Yaml;

// Symfony's parser never instantiates PHP objects unless you pass
// PARSE_OBJECT or PARSE_OBJECT_FOR_MAP. Do not pass either for input you did
// not write. The ext-yaml alternative, yaml_parse(), is governed by the
// yaml.decode_php ini setting, which is off by default; check it.
$data = Yaml::parse($source, Yaml::PARSE_EXCEPTION_ON_INVALID_TYPE);

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) write_node($w, (string) $k, $v);
    } elseif (is_bool($value)) {
        $w->text($value ? 'true' : 'false');
    } elseif ($value !== null) {
        $w->text((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) converts directly.
yq -p=yaml -o=xml '.' config.yaml

# yq writes no XML declaration and no wrapper, so a multi-key document
# produces several roots. Wrap it first:
yq -p=yaml -o=xml '{"root": .}' config.yaml

# A multi-document stream needs collecting into one root explicitly, or yq
# emits one XML fragment per document:
yq ea -p=yaml -o=xml '{"documents": {"document": [.]}}' manifests.yaml

# Always check the result. yq does not sanitise element names, so a key with
# a space in it produces XML that will not parse:
yq -p=yaml -o=xml '{"root": .}' config.yaml | xmllint --noout --nonet -

The flag to get right in every one of these is the loader, not the writer. yaml.load in Python, SnakeYAML's default Constructor in Java and yaml_parse with yaml.decode_php enabled will all build arbitrary objects from tags in the document. A YAML file is data until you use a loader that lets it be something else.

Common questions

Is my YAML uploaded anywhere?

No. The YAML parser and the XML writer are both JavaScript running in this tab, and there is no server-side component for them to reach. Open the Network tab in your developer tools, paste a document, and you will see the page's own assets load once and then nothing.

Worth checking here specifically. YAML is where configuration lives: Kubernetes secrets, CI pipeline variables, Ansible inventories with hostnames and usernames, compose files with database passwords in them.

Why did my postcode, version number or ID change?

Because YAML changed it, not the XML writer. YAML infers a scalar's type from how it is spelled, so 01730 is the number 1730, 1.10 is the float 1.1, and a nineteen-digit identifier will not fit in a double. All of that happens inside the YAML parser, before anything XML-related runs.

The fix is in the YAML: quote the value. '01730', '1.10' and '9007199254740993' all arrive as strings and are written exactly as typed. If a generator produced the YAML, it should have been quoting them.

What happens to a YAML file with several documents separated by ---?

All of them are read and wrapped. You get a single <documents> element with one <document> child per YAML document, and the notes panel says how many were found.

The alternative, which most converters choose, is to convert the first document and silently ignore the rest. That is a bad default for Kubernetes manifests, where one file routinely holds a Deployment, a Service and a ConfigMap, and losing two of the three is not something you notice until a deploy fails.

How are anchors, aliases and merge keys handled?

They are resolved by the parser and then fully expanded in the output. An anchor marks a node, an alias refers back to it, and a merge key applies one mapping into another. None of the three exists in XML and none survives.

What you get is correct but can be much larger than the input: one base block aliased into forty services produces forty complete copies. That is what the YAML meant, it is just that YAML let you write it once. The expansion happens in this tab's memory, so heavy aliasing can be slow.

Can I get values as XML attributes instead of child elements?

Yes. Prefix the key in your YAML with the attribute prefix shown in the control row, which is @_ by default, so a key of '@_id' with the value 7 becomes an id attribute on the enclosing element rather than an <id> child.

You have to quote that key. A plain scalar may not begin with @, which YAML reserves, so an unquoted @_id is a parse error and the message will complain about indentation rather than about the character. Put only scalars there: an attribute value cannot contain structure, so a mapping under an @_ key produces a stringified mess rather than nested XML.

Is the XML it produces valid?

It is well-formed, which is a different and weaker claim. Every element is closed, there is exactly one root, the ampersand, the less-than sign and the ]]> sequence are escaped in text, attribute values additionally escape the double quote and the whitespace characters, and a UTF-8 declaration is written at the top.

Validity means matching a schema, and YAML carries none to derive one from. If the XML is going somewhere that validates, take it to the XSD validator with the schema that system publishes.

Related tools

Background reading