Validating XML Documents Against XSD in Go
XML is still the backbone of countless enterprise integrations: SOAP services, financial messaging formats such as ISO 20022, government e-invoicing (for example the Italian FatturaPA), industrial protocols, and configuration exchange between heterogeneous systems. In all of these cases it is not enough for a document to be well-formed; it must also be valid, meaning it conforms to a contract expressed as an XML Schema Definition (XSD). This article explains how to perform XSD validation in Go, why the standard library cannot do it alone, which libraries fill the gap, and how to deploy the result reliably.
What XSD validation actually checks
An XSD describes the permitted structure and data types of an XML document: which elements may appear, in what order, how many times, with which attributes, and what lexical space each value must belong to. Validating against a schema answers a stricter question than well-formedness. A well-formed document merely has balanced tags and correctly quoted attributes. A valid document additionally respects every constraint declared in the schema, such as a quantity being a positive integer or an orderid attribute being mandatory.
Throughout the article we will use a small but representative schema for a shipping order.
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="shiporder">
<xs:complexType>
<xs:sequence>
<xs:element name="orderperson" type="xs:string"/>
<xs:element name="shipto">
<xs:complexType>
<xs:sequence>
<xs:element name="name" type="xs:string"/>
<xs:element name="address" type="xs:string"/>
<xs:element name="city" type="xs:string"/>
<xs:element name="country" type="xs:string"/>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="item" maxOccurs="unbounded">
<xs:complexType>
<xs:sequence>
<xs:element name="title" type="xs:string"/>
<xs:element name="note" type="xs:string" minOccurs="0"/>
<xs:element name="quantity" type="xs:positiveInteger"/>
<xs:element name="price" type="xs:decimal"/>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:sequence>
<xs:attribute name="orderid" type="xs:string" use="required"/>
</xs:complexType>
</xs:element>
</xs:schema>
A conforming instance looks like this.
<?xml version="1.0" encoding="UTF-8"?>
<shiporder orderid="889923">
<orderperson>John Smith</orderperson>
<shipto>
<name>Ola Nordmann</name>
<address>Langgt 23</address>
<city>4000 Stavanger</city>
<country>Norway</country>
</shipto>
<item>
<title>Empire Burlesque</title>
<quantity>1</quantity>
<price>10.90</price>
</item>
</shiporder>
An instance that omits the required orderid attribute and supplies a non-positive quantity is well-formed but invalid, and it is exactly the kind of input we want to reject with a precise, line-numbered error.
Why the standard library is not enough
Go's encoding/xml package parses XML into structs and can enforce a handful of expectations through struct tags, but it deliberately does not implement XSD. It has no notion of a schema, of complex-type derivation, of facets such as minInclusive, or of the full XML Schema type system. At most, encoding/xml guarantees that the input is well-formed and that it can be unmarshalled into the Go types you declared. Any structural or data-type rule expressed in an XSD is simply invisible to it.
The reason is scope. A conformant XSD 1.0 processor is a large and subtle piece of software: it must resolve imports and includes, evaluate identity constraints, support regular-expression facets, and honour namespace-aware content models. Reimplementing that in pure Go is a substantial undertaking, and the Go ecosystem has never converged on a complete, well-maintained pure-Go implementation. In practice, robust XSD validation in Go is delegated to libxml2, the same battle-tested C library that powers xmllint and much of the XML tooling in the Unix world.
The libxml2 dependency
Both mainstream Go libraries for XSD validation are cgo bindings around libxml2. This has one immediate practical consequence: you need the libxml2 development headers at build time and the shared library at run time, and CGO_ENABLED must be set to 1. On Debian or Ubuntu the packages are installed as follows.
# Debian / Ubuntu
sudo apt-get update
sudo apt-get install -y libxml2 libxml2-dev pkg-config
On Alpine, which many container images are based on, the equivalent packages come from apk.
# Alpine
apk add --no-cache libxml2 libxml2-dev pkgconfig build-base
On macOS with Homebrew, install libxml2 and expose it to pkg-config.
# macOS
brew install libxml2 pkg-config
export PKG_CONFIG_PATH="$(brew --prefix libxml2)/lib/pkgconfig:$PKG_CONFIG_PATH"
Keep this dependency in mind: it shapes everything from local development to the final container image, and we return to it in the deployment section.
Approach one: go-xsd-validate
The github.com/terminalstatic/go-xsd-validate package was written specifically to validate XML against XSD quickly and under load, for example inside HTTP handlers that receive XML request bodies. Its defining feature is that it preloads and compiles the schema once into memory, then reuses that compiled schema for every subsequent validation. It also exposes structured error information, including line numbers, rather than a single opaque string.
Install it with go get.
go get github.com/terminalstatic/go-xsd-validate
The lifecycle has three moving parts. You call Init once for the whole process and Cleanup at shutdown. You build an xsd handler from the schema and keep it alive for as long as you intend to validate. For each document you build a short-lived xml handler and validate it against the schema handler. Every handler wraps C memory, so each one must be freed explicitly; the Go garbage collector will not reclaim it for you.
package main
import (
"fmt"
"log"
"os"
"github.com/terminalstatic/go-xsd-validate"
)
func main() {
// Init must run exactly once per process before any parsing.
if err := xsdvalidate.Init(); err != nil {
log.Fatalf("init libxml2: %v", err)
}
defer xsdvalidate.Cleanup()
// Compile the schema once. Keep this handler for the whole lifetime
// of the program and reuse it for every document you validate.
xsdHandler, err := xsdvalidate.NewXsdHandlerUrl(
"shiporder.xsd",
xsdvalidate.ParsErrDefault,
)
if err != nil {
log.Fatalf("parse schema: %v", err)
}
defer xsdHandler.Free()
// Read the document to validate.
doc, err := os.ReadFile("shiporder.xml")
if err != nil {
log.Fatalf("read document: %v", err)
}
// Build a per-document handler, validate, then free it.
xmlHandler, err := xsdvalidate.NewXmlHandlerMem(doc, xsdvalidate.ParsErrDefault)
if err != nil {
log.Fatalf("parse document: %v", err)
}
defer xmlHandler.Free()
err = xsdHandler.Validate(xmlHandler, xsdvalidate.ValidErrDefault)
if err == nil {
fmt.Println("document is valid")
return
}
// A ValidationError carries one entry per schema violation.
if verr, ok := err.(xsdvalidate.ValidationError); ok {
for _, e := range verr.Errors {
fmt.Printf("line %d: %s\n", e.Line, e.Message)
}
return
}
// Anything else is a parser or library error, not a schema violation.
log.Fatalf("validation failed: %v", err)
}
The distinction in the final block matters. A ValidationError means the document parsed correctly but breaks the schema, and its Errors slice gives you the individual violations with their line numbers. Any other error type means something went wrong before validation could even run, such as malformed XML or an unparsable schema. Treating those two cases differently is what lets you return a clean, actionable response to a caller instead of a generic failure.
Reusing the compiled schema
The single most important performance property of this library is that schema compilation is expensive and validation is cheap. Compiling a large schema, especially one with many imports, can dominate the cost of a request. You should therefore compile the schema exactly once at startup and share the resulting XsdHandler across all goroutines. The library's validation path is designed to be called concurrently, so a shared handler behind an HTTP server is the intended usage, not an abuse of it.
A small wrapper makes this pattern explicit and keeps the C-memory discipline in one place.
package validation
import (
"fmt"
"github.com/terminalstatic/go-xsd-validate"
)
// Validator holds a compiled schema that is safe to reuse across goroutines.
type Validator struct {
xsd *xsdvalidate.XsdHandler
}
// NewValidator compiles the schema at the given path once.
func NewValidator(schemaPath string) (*Validator, error) {
xsd, err := xsdvalidate.NewXsdHandlerUrl(schemaPath, xsdvalidate.ParsErrDefault)
if err != nil {
return nil, fmt.Errorf("compile schema: %w", err)
}
return &Validator{xsd: xsd}, nil
}
// Validate checks a single in-memory document against the compiled schema.
// It returns a nil error for a valid document and a slice of human-readable
// messages for schema violations.
func (v *Validator) Validate(doc []byte) ([]string, error) {
xml, err := xsdvalidate.NewXmlHandlerMem(doc, xsdvalidate.ParsErrDefault)
if err != nil {
return nil, fmt.Errorf("parse document: %w", err)
}
defer xml.Free()
err = v.xsd.Validate(xml, xsdvalidate.ValidErrDefault)
if err == nil {
return nil, nil
}
if verr, ok := err.(xsdvalidate.ValidationError); ok {
messages := make([]string, 0, len(verr.Errors))
for _, e := range verr.Errors {
messages = append(messages, fmt.Sprintf("line %d: %s", e.Line, e.Message))
}
return messages, nil
}
return nil, fmt.Errorf("validation error: %w", err)
}
// Close releases the C resources held by the compiled schema.
func (v *Validator) Close() {
v.xsd.Free()
}
Note the contract of Validate: a returned error means the process could not perform validation at all, whereas a non-empty slice of messages with a nil error means validation ran and the document failed. Keeping those channels separate avoids conflating infrastructure problems with business-rule failures.
An HTTP validation endpoint
Because the compiled schema is shared and the per-request handler is short-lived, exposing validation over HTTP is straightforward. The server compiles the schema once at boot and each request only allocates the small XML handler.
package main
import (
"io"
"log"
"net/http"
"github.com/terminalstatic/go-xsd-validate"
"example.com/app/validation"
)
func main() {
if err := xsdvalidate.Init(); err != nil {
log.Fatalf("init libxml2: %v", err)
}
defer xsdvalidate.Cleanup()
v, err := validation.NewValidator("shiporder.xsd")
if err != nil {
log.Fatalf("build validator: %v", err)
}
defer v.Close()
http.HandleFunc("/validate", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "use POST", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 5<<20)) // cap at 5 MiB
if err != nil {
http.Error(w, "cannot read body", http.StatusBadRequest)
return
}
messages, err := v.Validate(body)
if err != nil {
// The document could not be parsed at all.
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if len(messages) > 0 {
w.WriteHeader(http.StatusUnprocessableEntity)
for _, m := range messages {
io.WriteString(w, m+"\n")
}
return
}
w.WriteHeader(http.StatusOK)
io.WriteString(w, "valid\n")
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
The choice of status codes reflects the earlier distinction. A body that cannot be parsed yields 400 Bad Request; a body that parses but violates the schema yields 422 Unprocessable Entity together with the list of violations; a conforming body yields 200 OK. The five-mebibyte cap with io.LimitReader is a simple guard against unbounded input, which matters because XML parsers can be a target for resource-exhaustion attacks.
Approach two: lestrrat-go/libxml2
The github.com/lestrrat-go/libxml2 module is a broader binding to libxml2. It offers a DOM interface, XPath evaluation, HTML parsing, and, in its xsd subpackage, schema validation. If your application already needs to walk the document tree or run XPath queries, this library lets you do parsing, querying, and validation through a single dependency rather than adding a second binding.
go get github.com/lestrrat-go/libxml2
The validation flow parses the schema with xsd.Parse, parses the document with libxml2.Parse, and calls Validate on the schema. Violations arrive as a SchemaValidationError whose Errors method returns one error per problem.
package main
import (
"fmt"
"log"
"os"
"github.com/lestrrat-go/libxml2"
"github.com/lestrrat-go/libxml2/xsd"
)
func main() {
// Parse and compile the schema.
schemaSrc, err := os.ReadFile("shiporder.xsd")
if err != nil {
log.Fatalf("read schema: %v", err)
}
schema, err := xsd.Parse(schemaSrc)
if err != nil {
log.Fatalf("parse schema: %v", err)
}
defer schema.Free()
// Parse the document into a libxml2 DOM.
docSrc, err := os.ReadFile("shiporder.xml")
if err != nil {
log.Fatalf("read document: %v", err)
}
doc, err := libxml2.Parse(docSrc)
if err != nil {
log.Fatalf("parse document: %v", err)
}
defer doc.Free()
// Validate the DOM against the schema.
if err := schema.Validate(doc); err != nil {
if serr, ok := err.(xsd.SchemaValidationError); ok {
for _, e := range serr.Errors() {
fmt.Printf("error: %s\n", e.Error())
}
return
}
log.Fatalf("validation failed: %v", err)
}
fmt.Println("document is valid")
}
Just as before, both the compiled schema and the parsed document hold C memory and must be freed with Free. The schema object can be compiled once and reused across goroutines for the same performance reasons discussed earlier; the per-request cost is limited to parsing the incoming document.
Choosing between the two libraries
The two options overlap heavily because they wrap the same C engine, but they serve slightly different needs.
- Reach for
go-xsd-validatewhen validation is the whole job, especially in a high-throughput service. Its API is narrow, it was designed around preloading and concurrency, and its structured errors with line numbers map cleanly onto HTTP responses. - Reach for
lestrrat-go/libxml2when you already need general libxml2 capabilities such as XPath, DOM traversal, or HTML parsing, and validation is one feature among several. Consolidating on one binding keeps the dependency graph smaller.
Both are cgo bindings, so neither removes the libxml2 build and runtime requirement. That constraint, rather than the API surface, is usually the deciding factor for teams that want a pure-Go binary.
Schemas with includes and imports
Real-world schemas are rarely a single file. They pull in other schemas through xs:include (same namespace) and xs:import (different namespace), and those references are resolved relative to the location of the schema being compiled. This is why compiling from a file path is more forgiving than compiling from a byte slice: given a path, libxml2 knows the base directory and can find sibling files, whereas an in-memory schema has no inherent location.
With go-xsd-validate, prefer NewXsdHandlerUrl with a real path when your schema has relative includes, so that the referenced files resolve correctly. With lestrrat-go/libxml2, the xsd package accepts a WithPath option that supplies the base location the parser should use to resolve relative references when you compile from memory.
A second, security-relevant point concerns remote references. Some schemas point at network locations, and a naive processor may try to fetch them at compile time. That is both a performance liability and an availability risk, because your validation now depends on a third-party host being reachable. The robust pattern is to vendor every schema locally, rewrite references to point at the local copies, and compile from those files. Bundling the schemas into your deployment artifact makes validation deterministic and offline.
The state of pure-Go alternatives
It is worth being candid about the pure-Go landscape, because it is a frequent source of confusion. Several Go packages mention XSD, but most of them do something other than validation. Tools in the family of xgen and go-xsd read an XSD and generate Go structs so you can unmarshal matching documents with encoding/xml. That is useful for producing typed code, but it is not schema validation: the generated structs capture element and attribute names, not the full constraint system, so a document that unmarshals successfully may still violate the schema.
As of this writing there is no widely adopted, feature-complete pure-Go XSD 1.0 validator. If your requirements demand real validation, the pragmatic answer remains a libxml2 binding. The only way to avoid cgo entirely is to shell out to an external validator such as xmllint, which trades the build-time dependency for a runtime process dependency and generally performs worse than an in-process compiled schema.
Deployment considerations
Because both libraries use cgo, the build and the runtime environment must both provide libxml2, and this is the part that most often surprises teams shipping containers. A multi-stage Docker build keeps the final image small while satisfying the dependency in both stages.
# Build stage: needs the libxml2 development headers and a C toolchain.
FROM golang:1.24-bookworm AS build
RUN apt-get update && apt-get install -y --no-install-recommends \
libxml2-dev pkg-config && rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ENV CGO_ENABLED=1
RUN go build -o /out/validator ./cmd/validator
# Runtime stage: needs only the shared library, not the headers.
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
libxml2 && rm -rf /var/lib/apt/lists/*
COPY --from=build /out/validator /usr/local/bin/validator
COPY shiporder.xsd /etc/app/shiporder.xsd
ENTRYPOINT ["/usr/local/bin/validator"]
Three points make or break this setup. First, CGO_ENABLED=1 is mandatory; the default in a cross-compilation context is often 0, which produces a binary that fails to build or link against libxml2. Second, the runtime stage still needs the libxml2 shared library even though it does not need the -dev package, so a truly empty scratch image will not work unless you statically link, which is awkward with musl and glibc mismatches. Third, if you build on Alpine remember that its musl libc differs from glibc, so build and run on the same base to avoid dynamic-linking surprises.
Finally, ship the schema files alongside the binary and load them from a known path, as the example does with /etc/app/shiporder.xsd. Compiling the schema at startup and failing fast if it cannot be compiled turns a subtle runtime problem into an obvious boot-time error.
Practical guidelines
A handful of habits keep XSD validation in Go fast, correct, and operationally boring. Compile each schema once at startup and reuse the compiled object across all goroutines, because compilation is the expensive step and validation is cheap. Free every handler and every parsed document explicitly, since they hold C memory that the Go garbage collector cannot reclaim. Distinguish parser errors from schema violations in your error handling and surface them differently, so callers can tell a malformed payload from a merely non-conforming one. Vendor all schemas locally and forbid network resolution to make validation deterministic and offline. Bound the size of incoming documents to protect against resource-exhaustion attacks. And treat the cgo dependency as a first-class part of your build and deployment story rather than an afterthought.
Conclusion
Go does not validate XML against XSD out of the box, and no mature pure-Go validator has emerged to change that. The practical, production-proven path is a cgo binding over libxml2, with go-xsd-validate being the natural choice for validation-focused services and lestrrat-go/libxml2 being convenient when you already need the wider libxml2 toolkit. Once you internalise the core discipline, that is, compile the schema once, reuse it concurrently, free C resources deliberately, and manage the libxml2 dependency through your build, you get validation that is both fast and dependable, with precise, line-numbered diagnostics that map cleanly onto whatever interface your application exposes.