CSV is the lowest-common-denominator format for data exports — spreadsheets, databases, and reporting tools all write it. XML is the required format for many enterprise systems, SOAP APIs, Android resource files, and legacy integrations. When you need to move data from one to the other, there's no universal standard for how CSV rows map to XML elements, so you need to understand the shape you're building.
This guide covers the structural choices, working code in JavaScript, Python, Go, and PHP, and the edge cases that break naive converters.
How CSV rows map to XML
CSV has no schema. Each row is flat — a list of column values separated by delimiters. XML is hierarchical. To convert between them you choose a convention:
| CSV concept | XML representation |
|---|---|
| File | Root element wrapping all records (e.g. <records>) |
| Row | Repeated child element (e.g. <record>) |
| Column header | Child element name or attribute name |
| Cell value | Text content or attribute value |
| Empty cell | Empty element (<city/>) or omit the element |
The most common convention uses column headers as child element names:
CSV input:
name,age,city
Alice,30,Podgorica
Bob,25,Budva
XML output:
<?xml version="1.0" encoding="UTF-8"?>
<records>
<record>
<name>Alice</name>
<age>30</age>
<city>Podgorica</city>
</record>
<record>
<name>Bob</name>
<age>25</age>
<city>Budva</city>
</record>
</records>
The alternative is to put values as attributes (<record name="Alice" age="30" city="Podgorica"/>), which produces more compact XML but loses the ability to represent multi-line or structured values.
Why CSV-to-XML is non-trivial
| Problem | What goes wrong |
|---|---|
| Special XML characters | &, <, >, ", ' in CSV cells break XML syntax |
| Invalid element names | Column headers like "First Name" or "123id" produce invalid XML tags |
| Quoted CSV fields | "New York, NY" must be parsed as one field, not two |
| Multi-line fields | CSV allows newlines inside quoted fields; naive split-on-newline fails |
| Encoding | CSVs from Excel are often Windows-1252; XML expects UTF-8 |
| Empty headers | Extra commas create nameless columns that become invalid XML |
JavaScript
Browser (PapaParse + manual XML builder)
import Papa from "papaparse";
function escapeXml(str) {
return String(str)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function sanitizeTag(name) {
// XML element names must start with letter or _, no spaces
return name.trim().replace(/\s+/g, "_").replace(/[^a-zA-Z0-9_\-\.]/g, "").replace(/^([^a-zA-Z_])/, "_$1") || "field";
}
function csvToXml(csvText, rootTag = "records", rowTag = "record") {
const { data, errors } = Papa.parse(csvText, {
header: true,
skipEmptyLines: true,
});
if (errors.length) throw new Error(errors[0].message);
const headers = Object.keys(data[0] || {}).map(sanitizeTag);
const originalHeaders = Object.keys(data[0] || {});
const rows = data.map((row) => {
const fields = originalHeaders
.map((h, i) => ` <${headers[i]}>${escapeXml(row[h] ?? "")}</${headers[i]}>`)
.join("\n");
return ` <${rowTag}>\n${fields}\n </${rowTag}>`;
});
return `<?xml version="1.0" encoding="UTF-8"?>\n<${rootTag}>\n${rows.join("\n")}\n</${rootTag}>`;
}
// Usage
const csv = `name,age,city\nAlice,30,Podgorica\nBob,25,Budva`;
console.log(csvToXml(csv));
Node.js (built-in csv-parse + xml2js)
import { parse } from "csv-parse/sync";
import { Builder } from "xml2js";
function csvToXml(csvText, rootName = "records", rowName = "record") {
const records = parse(csvText, { columns: true, skip_empty_lines: true });
// xml2js handles escaping automatically
const obj = {
[rootName]: {
[rowName]: records,
},
};
const builder = new Builder({ xmldec: { version: "1.0", encoding: "UTF-8" } });
return builder.buildObject(obj);
}
xml2js escapes XML special characters automatically, so you don't need to do it manually. The caveat: column headers become element names as-is, so invalid characters will still produce broken XML.
Python
Standard library (csv + xml.etree.ElementTree)
import csv
import io
import re
import xml.etree.ElementTree as ET
def sanitize_tag(name: str) -> str:
"""Turn an arbitrary string into a valid XML element name."""
tag = re.sub(r"\s+", "_", name.strip())
tag = re.sub(r"[^a-zA-Z0-9_\-\.]", "", tag)
if not tag or not re.match(r"[a-zA-Z_]", tag):
tag = f"_{tag}"
return tag or "field"
def csv_to_xml(
csv_text: str,
root_tag: str = "records",
row_tag: str = "record",
encoding: str = "utf-8",
) -> str:
reader = csv.DictReader(io.StringIO(csv_text))
headers = reader.fieldnames or []
safe_tags = {h: sanitize_tag(h) for h in headers}
root = ET.Element(root_tag)
for row in reader:
record = ET.SubElement(root, row_tag)
for header in headers:
child = ET.SubElement(record, safe_tags[header])
child.text = row.get(header) or ""
ET.indent(root, space=" ") # Python 3.9+
return ET.tostring(root, encoding="unicode", xml_declaration=True)
# Usage
csv_text = "name,age,city\nAlice,30,Podgorica\nBob,25,Budva"
print(csv_to_xml(csv_text))
xml.etree.ElementTree escapes &, <, and > inside text nodes automatically. ET.indent() requires Python 3.9+; on older versions omit it (output will be on one line) or use minidom for pretty-printing.
With pandas (good for large files)
import csv
import io
import xml.etree.ElementTree as ET
import pandas as pd
def csv_to_xml_pandas(path: str, root_tag: str = "records", row_tag: str = "record") -> str:
df = pd.read_csv(path, dtype=str) # read everything as string to avoid type loss
df = df.fillna("") # replace NaN with empty string
root = ET.Element(root_tag)
for _, row in df.iterrows():
record = ET.SubElement(root, row_tag)
for col in df.columns:
child = ET.SubElement(record, col.replace(" ", "_"))
child.text = row[col]
ET.indent(root, space=" ")
return ET.tostring(root, encoding="unicode", xml_declaration=True)
dtype=str prevents pandas from silently converting "0123" to 123 or "true" to True.
Go
package main
import (
"bytes"
"encoding/csv"
"encoding/xml"
"fmt"
"regexp"
"strings"
)
var (
reSpaces = regexp.MustCompile(`\s+`)
reInvalid = regexp.MustCompile(`[^a-zA-Z0-9_\-\.]`)
reStart = regexp.MustCompile(`^[^a-zA-Z_]`)
)
func sanitizeTag(name string) string {
tag := reSpaces.ReplaceAllString(strings.TrimSpace(name), "_")
tag = reInvalid.ReplaceAllString(tag, "")
if tag == "" || reStart.MatchString(tag) {
tag = "_" + tag
}
return tag
}
func csvToXML(csvText, rootTag, rowTag string) (string, error) {
r := csv.NewReader(strings.NewReader(csvText))
headers, err := r.Read()
if err != nil {
return "", err
}
safeTags := make([]string, len(headers))
for i, h := range headers {
safeTags[i] = sanitizeTag(h)
}
var buf bytes.Buffer
buf.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
buf.WriteString("<" + rootTag + ">\n")
for {
row, err := r.Read()
if err != nil {
break
}
buf.WriteString(" <" + rowTag + ">\n")
for i, val := range row {
// encoding/xml escapes special characters
escaped, _ := xml.Marshal(struct {
XMLName xml.Name `xml:"v"`
Value string `xml:",chardata"`
}{Value: val})
inner := strings.TrimPrefix(strings.TrimSuffix(string(escaped), "</v>"), "<v>")
fmt.Fprintf(&buf, " <%s>%s</%s>\n", safeTags[i], inner, safeTags[i])
}
buf.WriteString(" </" + rowTag + ">\n")
}
buf.WriteString("</" + rootTag + ">")
return buf.String(), nil
}
func main() {
csvText := "name,age,city\nAlice,30,Podgorica\nBob,25,Budva"
out, err := csvToXML(csvText, "records", "record")
if err != nil {
panic(err)
}
fmt.Println(out)
}
Using encoding/xml to marshal cell values ensures that &, <, and > are always properly escaped — don't build the XML by hand with fmt.Sprintf if you don't escape first.
PHP
<?php
function sanitizeTag(string $name): string {
$tag = preg_replace('/\s+/', '_', trim($name));
$tag = preg_replace('/[^a-zA-Z0-9_\-\.]/', '', $tag);
if ($tag === '' || !preg_match('/^[a-zA-Z_]/', $tag)) {
$tag = '_' . $tag;
}
return $tag ?: 'field';
}
function csvToXml(
string $csvText,
string $rootTag = 'records',
string $rowTag = 'record'
): string {
$lines = array_filter(explode("\n", str_replace("\r\n", "\n", $csvText)));
$rows = array_map('str_getcsv', array_values($lines));
if (empty($rows)) {
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<{$rootTag}/>";
}
$headers = array_shift($rows);
$safeTags = array_map('sanitizeTag', $headers);
$dom = new DOMDocument('1.0', 'UTF-8');
$dom->formatOutput = true;
$root = $dom->createElement($rootTag);
$dom->appendChild($root);
foreach ($rows as $row) {
$record = $dom->createElement($rowTag);
$root->appendChild($record);
foreach ($safeTags as $i => $tag) {
$child = $dom->createElement($tag);
$child->appendChild($dom->createTextNode($row[$i] ?? ''));
$record->appendChild($child);
}
}
return $dom->saveXML();
}
// Usage
$csv = "name,age,city\nAlice,30,Podgorica\nBob,25,Budva";
echo csvToXml($csv);
DOMDocument::createTextNode() handles XML escaping automatically. Do not use createElement() for cell values that contain special characters — always go through a text node.
Warning:
str_getcsvon a single line cannot handle multi-line quoted fields. For production CSV parsing usefgetcsvwithfopen('php://memory', 'r+')or a library likeleague/csv.
Quick reference
| Task | JavaScript | Python | Go | PHP |
|---|---|---|---|---|
| Parse CSV | Papa.parse / csv-parse |
csv.DictReader / pandas.read_csv |
encoding/csv |
str_getcsv / fgetcsv |
| Build XML | xml2js.Builder / manual |
xml.etree.ElementTree |
encoding/xml |
DOMDocument |
| XML escaping | Auto (xml2js) / manual | Auto (ET) | Manual via xml.Marshal |
Auto (createTextNode) |
| Sanitize headers | Manual regex | Manual regex | Manual regex | Manual regex |
| Pretty-print | xml2js with explicitArray |
ET.indent (3.9+) |
Manual | formatOutput = true |
| Large files | Streaming PapaParse | pandas chunks |
csv.Reader rows |
fgetcsv line-by-line |
Common pitfalls
1. Not escaping XML special characters
If a CSV cell contains &, <, >, ", or ' and you interpolate it directly into XML string templates, you'll produce malformed XML. Always use a library's text-node builder or an explicit escape function.
2. Using column headers as-is
Headers like "First Name", "123abc", or "type" are invalid XML element names. "First Name" has a space (not allowed), "123abc" starts with a digit (not allowed), and "type" is a reserved XML attribute name. Sanitize all headers before using them as tag names.
3. Splitting on newlines to parse CSV
CSV allows quoted fields that contain newlines: "line1\nline2" is one cell. Splitting on \n will break such files. Use a proper CSV parser in every language.
4. Encoding mismatch (Windows Excel)
Excel's "Save as CSV" often produces Windows-1252 encoding, especially in European locales. If you see garbled characters like é instead of é, re-open the file with the correct encoding or use chardet / iconv to detect and convert it to UTF-8 before processing.
5. Assuming one root element per row The default "each row → child element" convention is the most common, but some systems expect one root element per row (one XML document per row). Know your target schema before converting.
6. Empty trailing columns
A trailing comma (Alice,30,) adds an empty header or an empty last column. Map "" to a placeholder tag like field or skip empty-header columns entirely rather than producing <> (invalid) in your output.
FAQ
Can I convert CSV to XML without programming? Yes — use the Data Converter on this site. Paste your CSV, select the target format, and download the result. Everything runs in your browser; nothing is uploaded to a server.
What root and row element names should I use?
Use names that describe your data: <employees><employee>, <products><product>, <orders><order>. Many enterprise systems specify the required element names in their XSD schema — check the schema or API documentation first.
How do I add XML attributes instead of child elements?
Change the mapping so each CSV cell becomes an attribute of the row element: <record name="Alice" age="30" city="Podgorica"/>. This is more compact but attributes can't contain structured content and some XML parsers treat them differently. Use child elements unless the target schema requires attributes.
Can I convert CSV to XML with a schema (XSD)?
CSV-to-XML conversion produces a document; validation against an XSD schema is a separate step. In Python use lxml (xmlschema or etree.XMLSchema). In Java use javax.xml.validation. Generate the XML first, then validate it against the schema.
How do I handle CSV files with no header row?
Either add a header row before parsing, or generate element names like field1, field2, field3 based on the column index.
My CSV has nested data (JSON in a column). How do I handle it? Parse the JSON value first, then insert it as nested XML elements rather than as a text node. This requires a two-pass approach: CSV parse → row object → for JSON-valued cells, recursively convert to XML child elements.