Skip to content
datahacks.dev
XML

Parsing XML in Go With encoding xml and Struct Tags

Unmarshal XML into Go structs with tags, use a pointer for absent versus empty, catch bad booleans, and stream 200,000 orders in 0.8 seconds.

6 min read
Summarize in AI
Flat illustration of paired angle-bracket tags wrapped around a document, with the XML logo.

Go's standard library reads and writes XML through encoding/xml. It is small, it works without third-party code and it follows the same shape as encoding/json: tags on struct fields say how names map. It is a good fit for formats you control or that are simple, and it has some limits worth knowing before you build on it.

Struct tags

main.goGo
type Price struct {
	Currency string `xml:"currency,attr"`
	Amount   string `xml:",chardata"`
}
type Book struct {
	ID        string   `xml:"id,attr"`
	Available bool     `xml:"available,attr"`
	Title     string   `xml:"title"`
	Authors   []string `xml:"author"`
	Price     Price    `xml:"price"`
	Note      *string  `xml:"note"`
}
type Library struct {
	XMLName xml.Name `xml:"library"`
	Updated string   `xml:"updated,attr"`
	Books   []Book   `xml:"book"`
}

The tag grammar is short. xml:"id,attr" reads an attribute. xml:",chardata" takes the element's text (the empty name means "this element itself"). A bare name, xml:"title", reads a child element. A slice field with a child-element tag collects every matching child, so Authors gets both names of the first book and one name of the others. XMLName with a name checks the root: it makes Unmarshal fail if the root is something else.

Unmarshalling is one call:

Go
data, _ := os.ReadFile("library.xml")
var lib Library
if err := xml.Unmarshal(data, &lib); err != nil {
	panic(err)
}
Text
b1 | Cache Patterns | [Ana Lee Bo Chen] | 29.50 USD | available=true | note='Covers R&D'
b2 | SQL Tuning, 2nd Edition | [Cy Ortiz] | 44.00 USD | available=false | note=<absent>
b3 | Indexes "Explained" | [Di Park] | 250000 VND | available=true | note=''

Three typed books. The amount is a string, deliberately: Go has no decimal type in the standard library, and a float64 would turn 29.50 into 29.5 and invite rounding error. For money, keep the text and convert with a decimal package, or parse into integer cents (strconv and a split on the point) at the boundary. The authors are a slice. And the note is a *string, which is the key to the optional element.

Absent versus empty: use a pointer

encoding/xml leaves a field at its zero value when the element is missing, and a plain string field cannot tell a missing note from an empty one. A pointer can: nil when the element is absent, a pointer to "" when it is present and empty. The output above shows it: book 2 has note=<absent> (nil) and book 3 has note='' (non-nil, empty). The same trick works for numbers and booleans when zero is a legitimate value: *int and *bool. This is the idiomatic way, and it is the same distinction as in handling optional XML elements and missing values.

Errors

Type problems come back as errors from the standard library's conversions:

Go
var bad Library
err := xml.Unmarshal([]byte(`<library><book id="x" available="maybe"></book></library>`), &bad)
fmt.Println("bad bool ->", err)
var wrongRoot Library
err = xml.Unmarshal([]byte(`<other/>`), &wrongRoot)
fmt.Println("wrong root ->", err)
Text
bad bool -> strconv.ParseBool: parsing "maybe": invalid syntax
wrong root -> expected element type <library> but have <other>

This is stricter than the Java default (reading XML in Java with JAXB annotations): the bad boolean is an error that names the value, and the wrong root is rejected because of the XMLName tag. Syntax errors carry line numbers (XML syntax error on line 1: ...). Elements and attributes with no matching field are ignored without a message, so a renamed field in the input leaves your field at its zero value; compare counts or validate against a schema if that matters.

Namespaces

encoding/xml supports them with a space in the tag: xml:"urn:example:library book" matches the book element in that namespace, and the namespace appears in xml.Name.Space when you use tokens. Many real-world documents need only the local name, and the default behavior is to match local names when the tag has no namespace, which is forgiving and can confuse two vocabularies. The package does not handle namespace prefixes on output as nicely as one might like; to write prefixed elements you tend to write the attributes by hand. Check the output against what the consumer expects.

Entities and security

The standard decoder does not resolve external entities, and treats any entity it does not know as an error. I ran the external entity file through it:

Go
type T struct{ V string `xml:"v"` }
var t T
err = xml.Unmarshal([]byte(`<!DOCTYPE d [<!ENTITY x SYSTEM "file:///etc/hostname">]><d><v>&x;</v></d>`), &t)
fmt.Printf("external entity -> v=%q err=%v\n", t.V, err)
Text
external entity -> v="" err=XML syntax error on line 1: invalid character entity &x;

The field stayed empty and the decoder returned an error, so the file read did not happen. That is a safe default from a library that does not implement DTDs. It also means documents that legitimately define entities in a DOCTYPE do not parse with encoding/xml, and you can set Decoder.Entity to a map of extra entities you accept (a fixed list such as nbsp). There is no depth or size limit in the package; put a size cap in front (http.MaxBytesReader for requests) and consider a depth counter if you take hostile input (limiting XML size and depth for uploads).

Streaming with Decoder.Token

Unmarshal reads a whole document. For a big file, use the token API:

Go
dec := xml.NewDecoder(f)
n := 0
for {
	tok, err := dec.Token()
	if err == io.EOF { break }
	if err != nil { panic(err) }
	if se, ok := tok.(xml.StartElement); ok && se.Name.Local == "order" { n++ }
}
Text
stream: 200000 orders in 0.82s

200,000 orders in 0.82 seconds from a 36.6 MB file, without holding the document. Token returns a stream of values (StartElement, EndElement, CharData, Comment) which you switch on. To bind one record at a time, call dec.DecodeElement(&order, &se) when you reach an order start element: it fills your struct from that element and leaves the decoder after it. That gives typed records with constant memory, and it is the pattern I use for large files. The Rust reader on the same file took 0.09 seconds (parsing XML in Rust with quick-xml), so Go is not the fastest option, and 0.82 seconds is rarely the bottleneck.

Writing

xml.MarshalIndent(book, "", " ") writes the struct with the same tags; add xml.Header for the declaration. It escapes text (Covers R&amp;D), and attributes and ordering follow the struct. It will not write comments or processing instructions unless you add fields typed xml.Comment and similar, and prefix handling is minimal.

For the language overview see parsing XML in Java, C# and other typed languages. The package is documented in the encoding/xml reference, and the XML rules it implements in the XML 1.0 specification. For the equivalent in other streaming APIs see DOM versus SAX versus StAX parsing in Java. Test a path in the XPath tester.

When encoding/xml is the wrong tool in Go

You need XPath. The standard package has none. Third-party libraries (such as antchfx/xmlquery) add it; I did not test one.

The format is namespace-heavy. SOAP, SAML and similar are painful with struct tags alone. Use a library built for them, or write token-level code.

You need DTDs, validation or XSLT. Not in the standard library. Bind to libxml2 or run an external tool.

The documents are huge and speed matters. Token-level parsing is faster than Unmarshal, and a different parser may be faster still.

Also inTooling
Taggedgohow-to
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.