AlgoMaster Logo

XML Encoding

Low Priority22 min readUpdated June 6, 2026

JSON has taken over most modern APIs, but XML is still everywhere you don't choose the protocol. Legacy supplier feeds publish product catalogs as XML. SOAP services that survived the early 2000s still drive enterprise integrations. RSS and Atom feeds, shipping carrier APIs, government data exports, and many ERP systems all speak XML. Go's encoding/xml package handles all of it with an API that deliberately mirrors encoding/json, so most of what you already know about struct tags and streaming applies here too.

Where XML Still Shows Up

You rarely choose XML for a new system, but you read and write it whenever you talk to an older one. A few concrete places it appears in production:

  • Supplier and partner feeds. A wholesale supplier publishes its product catalog as a nightly XML file dropped on an FTP server. Your store imports it.
  • SOAP web services. Shipping carriers (FedEx, UPS), tax services, and banking systems still expose SOAP endpoints. Each request and response is an XML envelope.
  • RSS and Atom feeds. A "new products" feed, a blog feed, a status feed. RSS is XML with a specific schema.
  • Configuration formats. Some Java-rooted tools (Maven, Spring) use XML config. If your Go service launches one or parses its output, it's reading XML.
  • Legacy ERP and accounting systems. Order exports, invoice imports, and inventory updates often move as XML files between systems.

In all of these, XML is the boundary between Go code and an outside system. Inside your service, you parse to structs, do the work, and serialize back only when handing data to the legacy system. So encoding/xml is mostly a translation layer: turn XML into Go structs at the door, turn Go structs into XML at the exit.

The API is intentionally close to encoding/json. If you've used json.Marshal and struct tags, you already know most of the shape. The differences come from XML's extra features: attributes, namespaces, mixed content, and nested element paths.

The encoding/xml Package

The package ships with the standard library and centers on four functions and two stream types:

Function or TypePurpose
xml.Marshal(v) ([]byte, error)Encode a Go value as XML bytes (one line, no indentation)
xml.MarshalIndent(v, prefix, indent)Encode with pretty-printing for readability
xml.Unmarshal(data, v) errorParse XML bytes into a Go value
xml.NewEncoder(w)Streaming encoder writing to an io.Writer
xml.NewDecoder(r)Streaming decoder reading from an io.Reader
xml.HeaderThe constant <?xml version="1.0" encoding="UTF-8"?>\n

A round trip with a product struct looks almost identical to the JSON version:

The struct's name becomes the root element (<Product>). Each exported field becomes a child element, and the xml:"id" tag controls the element name. Unexported fields are skipped entirely, exactly like JSON. And xml.MarshalIndent produces human-readable output with the prefix and indent strings you pass in.

One thing the output is missing is the XML declaration line. If you're writing XML that another tool will parse strictly, you usually want it. Prepend the xml.Header constant:

xml.Header is just a string constant. Marshal and MarshalIndent don't add it for you because they're meant to be composable: you might marshal a fragment that goes inside a larger document, and there only one declaration belongs at the top.

Struct Tags: Mapping Go to XML

XML has more shapes than JSON. A value can live in three places: as an element body (<price>12.99</price>), as an attribute (<product price="12.99"/>), or as raw text inside an element (mixed content). The struct tag syntax encodes all of these.

Element Name

The basic form is xml:"name". The field becomes an element with that name.

Attributes

A field marked xml:"name,attr" becomes an attribute on the enclosing element. Attributes are usually used for identifiers, metadata, or short fixed-format values:

The id and sku show up as attributes on <product>. name and price stay as child elements. The first field, XMLName xml.Name with a tag of xml:"product", controls the name of the root element itself. Without it, the root would be <Product> (the Go struct name verbatim).

Character Data

To capture the text content inside an element while also reading attributes, use xml:",chardata". This is common in mixed-content documents where an element has both attributes and a text body:

The code attribute lands in Code. The 12.99 text inside the element lands in Amount. Without ,chardata, there'd be no way to capture that body alongside the attribute.

Inner XML and CDATA

Sometimes you want the raw inner content of an element without parsing it. xml:",innerxml" gives you that, useful for pass-through cases where a description field may contain HTML markup:

Description holds the literal bytes between the opening and closing tags. No parsing, no escaping changes.

For the encode side, when a field contains characters that XML would otherwise escape (<, >, &), you can request CDATA wrapping with xml:",cdata":

Without ,cdata, the special characters would have been escaped as &lt;, &gt;, &amp;. CDATA sections preserve them literally and signal to consumers that the content is opaque.

Nested Element Paths

XML often nests elements two or three levels deep, with the inner element being the only thing you actually care about. The xml:"a>b>c" syntax flattens that:

The > operator creates the intermediate <shipping> and <address> wrappers automatically. On the parse side, the same tag tells the decoder to descend through those wrappers and pull the inner text. This is one of the most useful tag features when you're reading legacy XML where the schema overuses nesting.

Root Element Names

The XMLName xml.Name field acts as the root element name on marshal and as a check on unmarshal. If you define XMLName xml.Name \xml:"product"\` and try to unmarshal an XML document whose root is <item>`, the decoder returns an error. That guards against accidentally accepting the wrong shape of document:

If you leave XMLName off, the decoder accepts whatever root element is present and uses the struct name on marshal. Use XMLName when the schema is fixed and you want type safety against drift.

Summary of XML Struct Tags

TagMeaning
xml:"name"Map field to element named name
xml:"name,attr"Map field to attribute named name on the parent element
xml:",chardata"Use the element's text body
xml:",innerxml"Use the element's raw inner XML, unparsed
xml:",cdata"Wrap text in a CDATA section on marshal
xml:",comment"Treat as an XML comment <!-- ... -->
xml:"a>b>c"Auto-create or skip through nested wrapper elements
xml:"-"Skip the field entirely
xml:"name,omitempty"Omit if the value is the zero value

Attributes vs Child Elements: When to Use Each

XML lets you express the same data either way:

versus

Both encode the same information. The Go side controls the difference through the ,attr modifier on the tag. The choice isn't arbitrary, though. Most XML schemas follow a few conventions:

Use Attributes ForUse Elements For
Identifiers (id, sku, version)Variable-length text (descriptions, addresses)
Metadata about the elementThe actual data the element represents
Short, fixed-format valuesRepeating or list-shaped values
Values that don't make sense to nestValues that contain other structured data

When you're mapping a Go struct to an XML schema you control, lean toward elements. They're easier to extend (you can add child elements later without breaking attribute layout), they support repetition, and they handle nested structure. Attributes are best for cases where the value is genuinely a property of the parent rather than its content.

When you're mapping to a fixed external schema, follow what the schema says. The supplier's XSD or the carrier's WSDL is the source of truth, and you write tags to match.

Slices Become Repeated Elements

XML has no array syntax. To represent multiple items of the same type, you repeat the element. Go's slice fields map naturally to this:

The slice field Products []Product with tag xml:"product" produces one <product> element per slice entry. On unmarshal, the decoder collects every <product> child under <catalog> into the slice. This is the same shape RSS, Atom, and most product feeds use.

Sometimes the schema wraps the repeated elements in a container, like <products><product>...</product></products>. The path syntax handles that without an extra struct:

That produces:

The wrapper element appears once, and each slice entry becomes a <product> inside it. The struct stays flat.

Mapping a Real Supplier Feed

A realistic example. A wholesale supplier publishes a daily product feed. Each entry has an ID and SKU as attributes, structured nested fields for category and price, and a free-text description that may contain HTML markup. The Go side reads it and prints a summary:

The flat Go struct collapses three layers of XML nesting (classification > category > name) into one field. Attributes become regular fields with ,attr. The description field uses ,innerxml to keep the embedded HTML markup intact. From the outside, the Go code looks like simple structs that happen to be loadable from XML.

Namespaces

XML namespaces qualify element names so two schemas can mix without colliding. A <title> from RSS and a <title> from Dublin Core can coexist if they're in different namespaces. The tag syntax for namespaces is xml:"namespace name", with the namespace URI before a space, then the element name:

The marshaller hoisted the namespace declaration up to the root as a default namespace (xmlns=...). All descendants inherit it. On the parse side, namespace-aware matching means a <title> element will only match the field if both the element name and the namespace URI agree.

You can also bind a namespace to a prefix by using the xmlns:prefix attribute pattern with xml.Attr, but for most cases the default-namespace approach above is enough. The key rule: if a schema uses namespaces, your tags must include them. Mismatched or missing namespaces are a common source of "decoder returns zero value" bugs.

Streaming with Encoder and Decoder

For small documents, xml.Marshal and xml.Unmarshal are the straightforward choice. For large documents, or for cases where the data flows through a pipe rather than living in a single byte slice, switch to xml.NewEncoder and xml.NewDecoder. They work the same way the JSON streaming versions do.

A streaming encoder writing an order export to a file:

The encoder writes incrementally. EncodeToken lets you emit raw structural tokens like start tags, end tags, and comments. Encode writes a full Go value as a complete subtree. Flush ensures any buffered bytes hit the writer before you close the file. This pattern is what you use when wrapping a large list of items inside a single root element without buffering the whole thing in memory.

A streaming decoder works the same way. For a giant supplier feed, you don't want to load the whole document into a []Item first. You want to read items one at a time as they stream in:

The pattern is the heart of SAX-style XML parsing in Go. Read tokens until you see a start element you care about. When you find one, hand it and the decoder to DecodeElement, which parses that subtree into a struct and advances the decoder past the matching end tag. The loop continues with the next token. Memory usage stays flat regardless of document size.

The decoder is a state machine driven by Token(). Each call returns the next structural piece: a start tag, an end tag, character data, a comment, or an io.EOF at the end. You filter for the elements that matter and skip the rest. DecodeElement is a shortcut: it takes the current start tag and reads the entire subtree into a Go value, leaving the decoder positioned just past the matching end tag.

Decoder Customization

Real-world XML is messy. Some documents are not strictly well-formed. Some declare custom character sets. Some use entity references the parser doesn't know about by default. The decoder exposes fields you can set before parsing to handle these:

FieldPurpose
dec.StrictWhen false, accept some forms of malformed XML (mismatched tags, missing close tags). Defaults to true.
dec.AutoCloseA list of element names treated as self-closing even if the source isn't (HTML-like behavior).
dec.EntityA map[string]string of custom entity references (e.g., &copy; to ©).
dec.CharsetReaderA function that wraps the reader to translate non-UTF-8 charsets into UTF-8.

Two of these matter often. First, CharsetReader. By default, Go's decoder only understands UTF-8. If a supplier feed declares <?xml version="1.0" encoding="ISO-8859-1"?>, the decoder errors out unless you hook in a charset converter. The standard solution is the golang.org/x/net/html/charset package:

charset.NewReaderLabel accepts the encoding name from the XML declaration and returns a reader that emits UTF-8 bytes. The decoder then parses normally.

Second, Entity. XML has five built-in entities (&lt;, &gt;, &amp;, &apos;, &quot;). Anything else (&copy;, &nbsp;, custom DTD entities) the decoder rejects unless you provide a mapping. For a feed that uses HTML-style named entities:

The decoder substitutes the entity references with the strings you gave it. Without the Entity map, parsing would fail with an "unknown entity" error.

Strict = false is a last resort. It lets the decoder accept malformed XML by guessing at the writer's intent. The reason it exists is HTML-like content that pretends to be XML but isn't quite. Use it sparingly. Strict mode catches real bugs in your input.

JSON vs XML: A Comparison

If you have a choice between the two formats, this table summarizes the practical trade-offs:

AspectJSONXML
VerbosityLower (no closing tags)Higher (every element has an end tag)
AttributesNone (only key-value)First-class (<el attr="..."/>)
Mixed contentAwkward (no native support)First-class (text and elements interleaved)
SchemaJSON Schema (separate spec, optional)XSD (mature, widely used in enterprise)
NamespacesNoneBuilt in
CommentsNot allowed by specFirst-class (<!-- ... -->)
Numeric typesNumbers (no integer/float distinction)Strings, parsed by consumer
Marshal speed (Go)FasterAbout 2-3x slower
Streamingjson.Decoder, line-delimited commonxml.Decoder with token loop
Typical use todayWeb APIs, configs, NoSQL storesLegacy APIs, SOAP, RSS/Atom, enterprise exports

For new internal APIs, JSON is the default. For talking to old systems that already speak XML, you don't have a choice. Go's encoding/xml gives you the tools either way, and most of the API mirrors what you already know from JSON.

A Round-Trip Example: Shipping Label

To pull everything together, here's a small round-trip example modeled on shipping label XML. The structure has attributes, nested elements, and a list of repeated items. This is the shape of a real handoff between an order system and a carrier:

The struct uses every feature covered in the chapter at once: a root element name from XMLName, attributes for order-id and carrier, nested address structs, a slice of items wrapped under a <contents> element via the path tag, line items with both attributes and chardata, and a round trip that produces the same shape on the way out. This is more or less what a real partner-integration codebase looks like.

Each kind of struct field flows into a different position in the resulting XML. XMLName controls the root tag name. ,attr fields appear inside the opening tag. Plain element fields become child elements. Path fields create or descend through intermediate wrapper elements. Slice fields produce one child per slice entry.

Common Pitfalls

A short list of things that bite people new to encoding/xml:

  • Unexported fields don't marshal. A lowercase struct field name means the package can't see it via reflection. Capitalize the field and use a tag for the XML name if you want it lowercase: Name string \xml:"name"\``.
  • Root element checks are strict when XMLName is present. If you declare XMLName xml.Name \xml:"product"\` and the input root is <item>`, decoding fails. Useful for safety, surprising if you didn't expect it.
  • No native arrays. Slices map to repeated elements. There's no [] in XML. A document with three <item> children unmarshals into a slice of three structs.
  • Forgetting Flush on streaming encoders. The encoder buffers tokens. Without enc.Flush(), the last bit of output may never reach the writer.
  • Namespaces match by URI, not prefix. A tag like xml:"a:Title" does not match what you think. Use xml:"http://example.com/2024 Title".
  • Default charset is UTF-8. Documents in other charsets fail until you set dec.CharsetReader.
  • Custom entities fail by default. Anything beyond the five built-in entities needs dec.Entity.
  • `xml.Header` is not added automatically. If a consumer requires the declaration line, prepend xml.Header yourself.

Quiz

XML Encoding Quiz

10 quizzes