All articles
Validators
37 min readBy DevUtilX Team

XML Validation, XSD Schemas and XPath

Learn the three levels of XML correctness, write XSD schemas, query with XPath, avoid namespace traps and XXE, with real validator output and runnable code.

XML Validation, XSD Schemas and XPath

An XML file can fail you in three different ways, and most developers only plan for the first. The parser can reject it because a tag is not closed. A schema can reject it because a quantity is 500 when the business allows at most 100. Or every check can pass and the file can still be wrong, because the price is in the wrong currency or the order refers to a customer who does not exist. Each failure needs a different tool, and mixing them up is why so many integrations break at three in the morning.

This guide is for developers, integration engineers and QA people who exchange XML with other systems: invoices, feeds, SOAP services, configuration files, office documents, sitemaps, SVG. You will learn what well-formed, valid and semantically correct really mean, how to read and write XML Schema (XSD) with confidence, how XPath finds data inside a document, and how to avoid the traps that waste the most time, namely namespaces, positional predicates and unsafe parsers. Every code sample and every error message below was produced by running the code in a sandbox, and the tools used are named so you can reproduce the results.

Three levels of XML correctness

"Is this XML correct?" has three different answers, and each one is checked by a different mechanism.

Level Question Who checks it Typical failure
Well-formed Does it follow the XML syntax rules? Every XML parser, always Mismatched tag, unescaped &, two root elements
Valid Does it match a declared grammar? A schema validator (DTD, XSD, RELAX NG) Missing required element, wrong data type, wrong order
Semantically correct Does it make sense for your application? Your code, business rules, sometimes Schematron Ship date before order date, unknown product code

The levels build on each other. A document that is not well-formed cannot be validated at all, because the validator cannot even build a tree. A document that is valid is guaranteed to be well-formed. And a valid document can still carry nonsense, because a schema describes shape and types, not meaning.

Level 1: well-formed

The XML specification defines a short list of rules. There must be exactly one root element. Every start tag needs a matching end tag, and elements must nest properly. Attribute values must be quoted. Names are case sensitive, so <Order> and <order> are different elements. The characters < and & must be escaped in text as &lt; and &amp;. An XML parser must stop at the first violation of these rules, which is a deliberate design choice. Unlike HTML, where browsers recover from broken markup, XML demands strictness so that two systems never disagree about what a document means.

Here is the real error reported by libxml2 for the smallest possible mismatch, a document containing <order><item>One</order>:

Opening and ending tag mismatch: item line 1 and order, line 1, column 25

Notice that the message names both tags and points to the column where the parser gave up. That column is where the parser noticed the problem, not necessarily where you made the mistake. The forgotten </item> is earlier. We cover this in the troubleshooting section.

Level 2: valid

Validity means the document conforms to a grammar that you or a standards body declared. The grammar says which elements may appear, in which order, how often, with which attributes, and what kind of text each may contain. Validation is optional in XML: a parser can check well-formedness only, and many do by default.

Level 3: semantically correct

Some rules cannot be expressed in a schema language, or are awkward to. "The total must equal the sum of the line items." "The delivery date cannot precede the order date." "This customer id must exist in the CRM." These checks belong in application code, in a rule language such as Schematron, or in XPath assertions. XSD 1.1 added assertions that cover part of this territory, but support in tools is uneven, so plan on application-level checks regardless.

A useful habit is to put the checks in order. Parse first, validate second, apply business rules third. Report each failure separately, because the person who fixes a missing end tag is rarely the person who fixes a wrong product code.

What XML Schema is, and where it came from

XML grew out of SGML, and so did its first schema language, the Document Type Definition (DTD). A DTD is declared in a <!DOCTYPE> section with lines such as <!ELEMENT order (customer, item+)>. DTDs are compact, but they have serious limits. They are not written in XML, they have almost no data types (everything is text), and they have no support for namespaces.

The W3C published XML Schema 1.0 as a Recommendation in 2001 to fix those gaps. An XSD is itself an XML document. It offers a rich set of built-in data types, such as integers, decimals, dates and booleans, lets you derive new types by restriction and extension, and works with XML namespaces. Version 1.1 followed, adding features such as assertions and conditional type assignment, though many popular tools still implement only 1.0. The XML Schema (W3C) article on Wikipedia gives a good overview of the two versions and their relationship to other schema languages.

XSD is the most widely deployed schema language, but not the only one.

  • DTD is the oldest. It is still used for entity declarations, in HTML 4 and XHTML history, and in some publishing pipelines.
  • XSD is the dominant choice for data exchange. Standards such as many industry invoice and messaging formats publish XSD files.
  • RELAX NG is an alternative with a simpler model and a compact non-XML syntax. It is common in documentation toolchains. It has weaker data typing on its own, but it can reuse the XSD datatype library.
  • Schematron is a rule-based language built on XPath assertions. It does not describe structure. It checks "if this then that" business rules, so it complements XSD instead of replacing it.

If you are choosing for a new project and your partners already supply XSD files, use XSD. If you author documentation formats, look at RELAX NG. If your problem is cross-field business rules, add Schematron or code.

XSD in depth, with a worked example

The best way to learn XSD is to read a complete schema. This one describes an order with a customer, one or more items and an optional note. We will take it apart piece by piece.

<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
           targetNamespace="urn:example:orders"
           xmlns="urn:example:orders"
           elementFormDefault="qualified">

  <xs:simpleType name="SkuType">
    <xs:restriction base="xs:string">
      <xs:pattern value="[A-Z]{3}-[0-9]{4}"/>
    </xs:restriction>
  </xs:simpleType>

  <xs:simpleType name="QuantityType">
    <xs:restriction base="xs:integer">
      <xs:minInclusive value="1"/>
      <xs:maxInclusive value="100"/>
    </xs:restriction>
  </xs:simpleType>

  <xs:simpleType name="StatusType">
    <xs:restriction base="xs:string">
      <xs:enumeration value="new"/>
      <xs:enumeration value="paid"/>
      <xs:enumeration value="shipped"/>
    </xs:restriction>
  </xs:simpleType>

  <xs:complexType name="ItemType">
    <xs:sequence>
      <xs:element name="name" type="xs:string"/>
      <xs:element name="quantity" type="QuantityType"/>
      <xs:element name="price" type="xs:decimal"/>
    </xs:sequence>
    <xs:attribute name="sku" type="SkuType" use="required"/>
  </xs:complexType>

  <xs:element name="order">
    <xs:complexType>
      <xs:sequence>
        <xs:element name="customer" type="xs:string"/>
        <xs:element name="item" type="ItemType" maxOccurs="unbounded"/>
        <xs:element name="note" type="xs:string" minOccurs="0"/>
      </xs:sequence>
      <xs:attribute name="id" type="xs:positiveInteger" use="required"/>
      <xs:attribute name="status" type="StatusType" default="new"/>
      <xs:attribute name="placed" type="xs:date"/>
    </xs:complexType>
  </xs:element>
</xs:schema>

Elements, attributes and the two kinds of type

Everything in XSD revolves around declaring elements and attributes and giving each a type. An element can contain text, other elements, or both. An attribute can only contain a simple value, and it cannot repeat or nest. That difference drives a design rule of thumb: use attributes for identifiers and small facts about an element (an id, a currency, a lang), and use elements for content, anything that might repeat, or anything that might grow structure later.

Types come in two families:

  • A simple type describes text with no child elements and no attributes. xs:integer, xs:date and our SkuType are simple types. Attributes always have simple types.
  • A complex type describes an element that has child elements, attributes, or both. ItemType is a complex type: it has three children and a sku attribute.

Built-in datatypes

XSD ships with a large library of built-in types. You will use a handful constantly.

Type Accepts Example
xs:string Any text Fountain pen
xs:integer Whole numbers of any size -7
xs:positiveInteger Integers of 1 or more 1001
xs:decimal Fixed-point numbers, no exponent 12.50
xs:boolean true, false, 1, 0 true
xs:date ISO 8601 date, YYYY-MM-DD 2026-03-14
xs:dateTime ISO 8601 date and time 2026-03-14T09:30:00Z
xs:anyURI A URI reference https://example.com/a
xs:ID / xs:IDREF Document-unique identifier and a reference to one b1

Using the right built-in type is the cheapest quality improvement in any schema. A field declared xs:string accepts everything, which means the validator can tell you nothing. Declare dates as xs:date, prices as xs:decimal, and counts as xs:nonNegativeInteger, and the validator starts catching mistakes for free.

Restrictions and facets

A restriction derives a new simple type from an existing one by narrowing its allowed values with facets. Our schema uses three:

  • pattern constrains text with a regular expression. SkuType requires three capital letters, a hyphen and four digits. XSD patterns are implicitly anchored to the whole value, so you do not write ^ and $. This surprises people who are used to partial matches.
  • minInclusive and maxInclusive bound numeric and date values. QuantityType allows 1 through 100. minExclusive and maxExclusive exist for open bounds.
  • enumeration lists the permitted values, and StatusType allows only new, paid and shipped.

Other facets you will meet are length, minLength and maxLength for string sizes, totalDigits and fractionDigits for decimals, and whiteSpace to control normalisation. A phone field limited to 7 to 15 characters, for example, is one minLength and one maxLength away. Facets only apply to compatible base types, so maxInclusive on a string is an error in the schema itself.

Content models: sequence, choice and all

A complex type that contains elements declares a content model with one of three compositors.

  • xs:sequence requires the children in the stated order. This is by far the most common, and it is the source of many "this element is not expected" errors.
  • xs:choice allows exactly one of the listed alternatives. A contact that must have an email or a phone, but not both, is a choice.
  • xs:all allows the children in any order, each at most once in XSD 1.0. It is convenient when order does not matter, but it cannot be combined freely with other compositors.

Occurrence: minOccurs and maxOccurs

Both attributes default to 1, so an element is required and appears once unless you say otherwise. minOccurs="0" makes it optional, as with note. maxOccurs="unbounded" lets it repeat without limit, as with item. Setting minOccurs="1" maxOccurs="5" allows between one and five. Attributes use a different mechanism: use="required", use="optional" (the default) or use="prohibited", along with optional default and fixed values.

Namespaces: targetNamespace and elementFormDefault

XML namespaces let documents from different vocabularies coexist without name clashes. An XSD declares the namespace it defines with targetNamespace. In our schema, every element and type it declares lives in urn:example:orders. The line xmlns="urn:example:orders" makes that the default namespace inside the schema so we can refer to SkuType without a prefix.

The setting elementFormDefault="qualified" decides whether locally declared elements, such as customer inside order, must carry the namespace in an instance document. With qualified, they do, and a document that sets a default namespace on its root, as ours does, satisfies this naturally. With the default value, unqualified, only the global elements are namespaced, and local children must have no namespace. Mixing this up produces a very confusing error, covered below. A good habit is to always set elementFormDefault="qualified", since most real-world schemas do.

Connecting an instance to its schema

Validation needs a schema and a document. There are two ways to link them. The instance can hint at the schema location with attributes from the xsi namespace:

<order xmlns="urn:example:orders"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="urn:example:orders order.xsd"
       id="1001">
  ...
</order>

The value of xsi:schemaLocation is a list of namespace and location pairs. For documents with no namespace use xsi:noNamespaceSchemaLocation. The second way, which is the safer one, is to ignore these hints and tell the validator which schema to use. Most libraries let you load the XSD yourself, and that is the right approach when you validate input from other people. The security section explains why.

Extension, restriction and reuse of complex types

You can build new complex types from existing ones. Extension appends content, in the way a subclass adds fields:

<xs:complexType name="EmployeeType">
  <xs:complexContent>
    <xs:extension base="PersonType">
      <xs:sequence>
        <xs:element name="badge" type="xs:string"/>
      </xs:sequence>
    </xs:extension>
  </xs:complexContent>
</xs:complexType>

If PersonType has a name element, an EmployeeType must contain name first and then badge. Extension always adds to the end of the parent's content, and I ran a test that confirms it: putting badge before name fails with This element is not expected. Expected is ( name ).

Restriction of a complex type goes the other way. It repeats the parent's content model in a narrower form, for instance changing maxOccurs from unbounded to 3. A restricted type must remain a valid subset of its parent, so any document valid against the restricted type is also valid against the base type. Complex restriction is verbose and rarely needed. Most schemas rely on simple type restriction and extension.

Keys and uniqueness, at a concept level

Plain content models cannot say "no two employees share a badge number." XSD handles this with identity constraints, which use XPath-like selectors and fields:

  • xs:unique says the selected values must not repeat, though they may be absent.
  • xs:key says the selected values must be unique and also must be present.
  • xs:keyref says a value must match an existing key, which works like a foreign key.

Each constraint has a selector, which picks the elements to compare, and one or more field entries, which pick the values on each. The selector and field expressions use a restricted XPath subset. I tested a unique constraint on badge. Two employees with the badge 1 produced this real message: Duplicate key-sequence ['1'] in unique identity-constraint 'uniqueBadge'. These constraints are valuable for catching duplicate identifiers early, but they cover only the document in front of the validator. They cannot check a value against your database.

Running real validation: valid and invalid documents

Theory is easier to trust when you see output. The experiments below used Python 3 with lxml 6.1.3, which wraps libxml2 version 2.14.6, and the xmllint command line tool from the same library. Both implement XSD 1.0. The schema is the one shown above.

First, a valid document:

<?xml version="1.0" encoding="UTF-8"?>
<order xmlns="urn:example:orders" id="1001" status="paid" placed="2026-03-14">
  <customer>Ada Lovelace</customer>
  <item sku="PEN-0001">
    <name>Fountain pen</name>
    <quantity>2</quantity>
    <price>12.50</price>
  </item>
  <item sku="INK-0042">
    <name>Blue ink</name>
    <quantity>5</quantity>
    <price>4.20</price>
  </item>
  <note>Gift wrap, please</note>
</order>

And this Python script, which loads the schema once and validates a file:

from lxml import etree

schema = etree.XMLSchema(etree.parse("order.xsd"))

doc = etree.parse("good.xml")
print("valid:", schema.validate(doc))
for e in schema.error_log:
    print("line %d: %s" % (e.line, e.message))

For the valid document the result is valid: True with an empty error log. xmllint --noout --schema order.xsd good.xml agrees and prints good.xml validates.

Now an invalid document. It has a bad id of zero, a status outside the enumeration, a date in the wrong format, and the elements are in the wrong order, with item before customer:

<order xmlns="urn:example:orders" id="0" status="refunded" placed="14/03/2026">
  <item sku="pen-1">
    <name>Fountain pen</name>
    <quantity>500</quantity>
    <price>twelve</price>
  </item>
  <customer>Ada Lovelace</customer>
</order>

The validator printed these actual messages:

line 2: Element '{urn:example:orders}order', attribute 'id': '0' is not a valid value of the atomic type 'xs:positiveInteger'.
line 2: Element '{urn:example:orders}order', attribute 'status': [facet 'enumeration'] The value 'refunded' is not an element of the set {'new', 'paid', 'shipped'}.
line 2: Element '{urn:example:orders}order', attribute 'placed': '14/03/2026' is not a valid value of the atomic type 'xs:date'.
line 3: Element '{urn:example:orders}item': This element is not expected. Expected is ( {urn:example:orders}customer ).

Three things are worth noticing. The element names appear in Clark notation, {namespace}localname, which tells you the namespace the validator resolved. The facet that failed is named in brackets, so you know whether it was the enumeration, the pattern or a bound. And the output stops reporting inside the items. Once a content model fails at the root, libxml2 does not descend into the unexpected element. That means a single structural error can hide several deeper ones. Fix the first message and run again.

To see the deeper errors, here is a second invalid document with the order fixed, but with problems inside the items:

<order xmlns="urn:example:orders" id="1002">
  <customer>Grace Hopper</customer>
  <item sku="pen-1">
    <name>Fountain pen</name>
    <quantity>500</quantity>
    <price>twelve</price>
  </item>
  <item sku="INK-0042">
    <name>Blue ink</name>
    <price>4.20</price>
    <quantity>1</quantity>
  </item>
</order>
line 4: Element '{urn:example:orders}item', attribute 'sku': [facet 'pattern'] The value 'pen-1' is not accepted by the pattern '[A-Z]{3}-[0-9]{4}'.
line 6: Element '{urn:example:orders}quantity': [facet 'maxInclusive'] The value '500' is greater than the maximum value allowed ('100').
line 7: Element '{urn:example:orders}price': 'twelve' is not a valid value of the atomic type 'xs:decimal'.
line 11: Element '{urn:example:orders}price': This element is not expected. Expected is ( {urn:example:orders}quantity ).

Every constraint in the schema now shows up: the pattern on the SKU, the upper bound on quantity, the decimal type on price, and the sequence order in the second item. A final document with a missing required attribute and an unknown element gave these two messages:

line 3: Element '{urn:example:orders}item': The attribute 'sku' is required but missing.
line 4: Element '{urn:example:orders}colour': This element is not expected. Expected is one of ( {urn:example:orders}item, {urn:example:orders}note ).

That last message is a good example of how to read the "Expected" list. After the items, the schema allows another item (because of unbounded) or the optional note, and nothing else. A colour element has no place.

The same check from the command line

You do not need Python to validate. The xmllint tool ships with libxml2 and is often already installed on Linux and macOS:

xmllint --noout --schema order.xsd good.xml bad.xml

It printed good.xml validates and then the same four messages for the bad file, each prefixed with bad.xml:2: style locations, and finished with bad.xml fails to validate. The exit code is non-zero on failure, which makes it a convenient CI step.

Validating without code: the DevUtilX tools in your browser

For a quick check, you do not have to write any code. DevUtilX runs both tools in your browser, so the document stays on your machine and nothing is uploaded.

Check well-formedness and structure with the XML Validator

  1. Open the XML Validator.
  2. Paste your XML into the input area.
  3. Run the validation.
  4. Read the result. If the document is well-formed, the tool confirms it. If not, it reports the problem with its position.
  5. Fix the first reported problem, and run again. A parser stops at the first well-formedness error, so later ones appear only after you fix earlier ones.

Treat the reported position as the place where the parser noticed the problem. For a mismatched tag, the real mistake is often earlier, at the unclosed element. If the document is long, tidy it first so the structure is visible. Our XML formatting and structure guide shows how consistent indentation makes a missing end tag obvious. If you need to compare a broken and a repaired version, the code diff guide explains how to see exactly what changed.

Test queries with the XPath Validator

  1. Open the XPath Validator.
  2. Paste the XML document you want to query.
  3. Type an XPath expression, such as //item[quantity > 2]/@sku.
  4. Run it and inspect the matches.
  5. Adjust the expression until it returns exactly the nodes you want, then copy it into your code.

Testing in the browser is faster than a compile and run cycle, and it lets you see immediately whether an expression returns nothing, which, as the namespace section below shows, is usually the first sign of a binding problem. Because everything runs locally, you can safely paste documents that contain customer data. As with any tool, still avoid pasting passwords or tokens.

XPath in depth

XPath is a language for selecting nodes in an XML document. It appears in XSLT, XQuery, XSD identity constraints, Schematron, browser APIs, test frameworks such as Selenium, and countless configuration systems. The XPath article on Wikipedia summarises its history, and the XPath 3.1 specification is the authoritative current reference.

An XPath expression works on a tree. The XML document is modelled as nodes: a document node at the top, then element nodes, attribute nodes, text nodes, comments and processing instructions. An expression starts at a context node and walks the tree, returning a set of nodes, a string, a number or a boolean.

The sample document

All the results below come from this catalog. I ran every expression in lxml and kept the output.

<?xml version="1.0"?>
<catalog>
  <book id="b1" lang="en">
    <title>XML in Practice</title>
    <author>Ng</author>
    <price currency="USD">29.99</price>
  </book>
  <book id="b2" lang="fr">
    <title>  Le   Schema  </title>
    <author>Dupont</author>
    <author>Martin</author>
    <price currency="EUR">19.50</price>
  </book>
  <book id="b3" lang="en">
    <title>XPath Basics</title>
    <author>Ng</author>
    <price currency="USD">9.00</price>
  </book>
</catalog>

Path expressions

A path is a series of steps separated by /. A leading / starts at the document root, so /catalog/book/title/text() walks from the root to each title and returns its text. A double slash // means "any depth", so //title finds every title no matter where it sits. A dot . is the current node and .. is its parent.

/catalog/book/title/text()  =>  ['XML in Practice', '  Le   Schema  ', 'XPath Basics']

Relative paths, which do not start with a slash, begin at the context node, which is the node the expression is evaluated from. This matters inside predicates and in XSLT templates.

Axes

Each step has an axis that says in which direction to move from the current node. The short syntax hides the axis in most cases, and the long syntax makes it explicit with axis::node-test.

Axis Selects Short form
child Direct children book (the default)
descendant Children, grandchildren and so on // is /descendant-or-self::node()/
parent The parent node ..
ancestor Parent, grandparent and up to the root none
following-sibling Later siblings under the same parent none
preceding-sibling Earlier siblings under the same parent none
attribute Attributes of the context element @
self The context node itself .

There are also following, preceding, ancestor-or-self and descendant-or-self. Axes such as following-sibling are what let XPath answer questions like "which price comes after this author?" Here are two real results:

//author[.='Martin']/preceding-sibling::author/text()   =>  ['Dupont']
//author[.='Dupont']/following-sibling::price/text()    =>  ['19.50']

Node tests

After the axis comes a node test, which filters by kind or name. A name such as book matches elements with that name. * matches any element. text() matches text nodes, node() matches any node, and comment() matches comments. On the attribute axis, @* matches any attribute. Beginners often write //book/title and expect the string, but this returns the element. Add /text() or wrap it in string() to get text.

Predicates and positional indexing

A predicate in square brackets filters the nodes selected by a step. It can test an attribute, a child value, a function, or a position.

Positions start at 1, not 0. That single fact causes more wrong results for newcomers than any other. //book[1] is the first book, and //book[0] matches nothing at all.

//book[2]/@id                      =>  ['b2']
//book[last()]/title/text()        =>  ['XPath Basics']
//book[position() < 3]/@id         =>  ['b1', 'b2']
//book[@lang='en']/@id             =>  ['b1', 'b3']
//book[author='Ng']/title/text()   =>  ['XML in Practice', 'XPath Basics']

A predicate containing a number is shorthand for position() = number. Predicates can be chained: //book[@lang='en'][2] first filters to English books and then takes the second of those, which is b3, while //book[2][@lang='en'] takes the second book first and then checks its language, which gives nothing. The order changes the result.

One more subtlety: //book[1] means "each book that is the first book child of its parent", not "the first book in the document". For a flat catalog the two are the same. In a document where books sit inside several shelves, //book[1] returns one book per shelf. To get a single result, wrap the expression: (//book)[1].

Operators and functions

XPath 1.0 has the usual comparison operators (=, !=, <, >, <=, >=), boolean and and or, arithmetic +, -, *, div and mod, and the union operator |. Note div for division, since / is the path separator. Beware of < in XML contexts. If you embed an expression inside an XML attribute, write &lt;.

The core function library covers most daily needs:

Function Purpose Example and real result
count(nodes) Number of nodes count(//book) gives 3.0
contains(s, t) Substring test //book[contains(title,'XML')]/@id gives ['b1']
normalize-space(s) Trim and collapse whitespace normalize-space(//book[2]/title) gives Le Schema
starts-with(s, t) Prefix test //book[starts-with(@id,'b')]
string(x) Convert to string string(//book[1]/@id) gives b1
sum(nodes) Numeric total sum(//price[@currency='USD']) gives 38.99 in effect
not(x) Boolean negation //book[not(@lang='en')]/@id gives ['b2']
position() and last() Context position and size used inside predicates
name() and local-name() Element name, with or without prefix name(//price[1]/ancestor::*[1]) gives book

Two behaviours in that table deserve explanation. First, numbers in XPath 1.0 are floating point, which is why count returns 3.0 in Python and why the sum of 29.99 and 9.00 printed as 38.989999999999995 in my run. For money, do not trust floating-point sums. Select the values and add them with decimal arithmetic in your own code, or use XPath 2.0 or later with xs:decimal. Second, //book[price > 15] compares each price converted to a number, so it returned ['b1', 'b2'], with 9.00 correctly excluded.

The whitespace function is the one to remember for real-world data. The second title has leading, trailing and doubled spaces. A comparison such as //title = 'Le Schema' fails, but //title[normalize-space()='Le Schema']/../@id returns ['b2'], as the run confirmed.

Counting is also useful as a validation tool. //book[count(author)>1]/@id returned ['b2'], the only book with two authors.

XPath 1.0 versus 2.0 and 3.x

The language has several versions, and which one you can use depends on your library.

  • XPath 1.0 (1999) is the version implemented by browsers (document.evaluate), libxml2 and therefore lxml, xmllint, and the standard libraries of many languages. Its data model is nodes, strings, numbers and booleans.
  • XPath 2.0 (2007) switched to sequences and added the full XSD type system. It introduced for, if and some/every expressions, richer string and date functions, and regular-expression functions such as matches() and replace().
  • XPath 3.0 and 3.1 added higher-order functions, maps and arrays, string concatenation with ||, and the simple map operator !. XPath 3.1 is the current Recommendation.

At a conceptual level, the jump from 1.0 to 2.0 matters most: sequences, real types and regular expressions turn many awkward workarounds into one-liners. If a function you read about in a tutorial does not exist in your environment, check which version your processor supports. I confirmed in the sandbox that lxml evaluates XPath 1.0, so expressions using matches() would not work there.

The namespace trap

If a developer's XPath returns nothing on a document that obviously contains the element, namespaces are the cause more often than not. The rule is simple and unforgiving: an XPath name without a prefix always means "no namespace". A default namespace on the document, written xmlns="...", does not change that. It puts the elements in a namespace, and an unprefixed XPath name still looks for elements without one.

Our good.xml declares xmlns="urn:example:orders" on its root. Here is what lxml returned:

from lxml import etree
t = etree.parse("good.xml")

print(t.xpath("//customer"))
# []

print(t.xpath("//o:customer/text()", namespaces={"o": "urn:example:orders"}))
# ['Ada Lovelace']

print(t.xpath("//*[local-name()='customer']/text()"))
# ['Ada Lovelace']

print(t.xpath("//o:item[o:quantity>2]/@sku", namespaces={"o": "urn:example:orders"}))
# ['INK-0042']

The first query returned an empty list, with no error. The fix is to bind a prefix of your choosing to the namespace URI in the code that runs the query. The prefix o exists only in your query. It does not need to match any prefix in the document, because only the URI counts. The local-name() form works without bindings, but it ignores the namespace entirely, which can match elements from other vocabularies, so use it for quick exploration and not for production code.

The same trap exists in other languages, and the cure is the same. In JavaScript with the xpath and @xmldom/xmldom npm packages, I ran this:

const fs = require("fs");
const { DOMParser } = require("@xmldom/xmldom");
const xpath = require("xpath");

const xml = fs.readFileSync("good.xml", "utf8");
const doc = new DOMParser().parseFromString(xml, "text/xml");

console.log("no prefix  :", xpath.select("//customer", doc).length);

const select = xpath.useNamespaces({ o: "urn:example:orders" });
console.log("with prefix:", select("//o:customer/text()", doc).map(String));
console.log("big skus   :", select("//o:item[o:quantity > 2]/@sku", doc).map((a) => a.value));
console.log("total items:", select("count(//o:item)", doc));
console.log("last item  :", select("string(//o:item[last()]/o:name)", doc));

The output was:

no prefix  : 0
with prefix: [ 'Ada Lovelace' ]
big skus   : [ 'INK-0042' ]
total items: 2
last item  : Blue ink

Versions used were @xmldom/xmldom 0.9.12 and xpath 0.0.36, with Node.js 22. Node has no built-in XML DOM or XPath engine, so you need packages like these. In a browser, document.evaluate with a namespace resolver does the job. On the command line, xmllint --xpath "//*[local-name()='item']/@sku" good.xml returned both SKUs.

Note that attributes behave differently: an unprefixed attribute is in no namespace even when its element is in one. That is why @sku works above without a prefix.

XPath, CSS selectors, XQuery and XSLT

XPath is often compared with neighbouring technologies. They overlap, but they are built for different jobs.

Technology What it is Best at Limits
XPath Expression language for selecting nodes Precise queries, upward and sideways navigation Read-only, selects but does not build new documents
CSS selectors Selector syntax for HTML and XML styling Short selections by class, id and tag in HTML Cannot move to a parent or select by text content in standard CSS
XQuery Query language built on XPath Querying collections, joins, constructing new XML or JSON Needs an XQuery engine, steeper learning curve
XSLT Template language for transforming XML Converting documents to other XML, HTML or text Verbose, a rules-based style takes getting used to

XPath is a building block for the other two: XQuery and XSLT both embed XPath expressions. CSS selectors are friendlier for web pages, but XPath can do things CSS cannot, such as select an element by its text, or walk up to an ancestor. For example, //td[normalize-space()='Total']/following-sibling::td[1] finds the cell next to a label, which is difficult in CSS. When you work on HTML, our HTML formatting and semantic markup guide covers the structure these expressions run over, and note that XPath over HTML works best when the HTML has been parsed into a tree by a tolerant parser.

Real-world use cases

Validating partner feeds. A retailer receives product feeds from suppliers. The XSD defines mandatory fields, price formats and allowed category codes. Rejecting a bad file at the front door, with the validator's message sent back to the supplier, saves hours of data cleaning.

Business documents. Electronic invoicing and many industry messaging standards publish XSD files so that every trading partner validates the same way. Cross-field rules, such as totals, then run in a second step.

Configuration files. Build tools, application servers and IDEs ship an XSD for their config files, so editors can offer autocompletion and flag errors as you type.

Web services. SOAP services describe their messages in XSD inside a WSDL file. Code generators read these schemas to produce typed client classes.

Extracting data. XPath is the standard way to pull a few values out of a large document, such as the price of one SKU or all links in a sitemap, without writing a full object mapping.

Testing. UI test frameworks locate elements with XPath, and API tests assert on XML responses with it, for example checking that count(//item) = 2.

Document formats. Office documents, EPUB books and SVG images are XML underneath. Validating them against their schemas catches structural damage. For SVG specifically, see our SVG formatting and editing guide and the SVG and XML minification guide.

Data migration. When moving from XML to JSON or a database, a schema gives you the exact list of fields and types, and XPath gives you the extraction. If you are mapping to JSON afterwards, the JSON formatting guide is a useful companion.

Common mistakes and pitfalls

  • Treating well-formed as valid. A parser that reports no errors has checked syntax only. Without a schema, a missing <price> passes silently.
  • Forgetting that XSD sequence order matters. The most common validation failure is a correct set of elements in the wrong order. The error says "not expected", which sounds like the element is unknown, but often it is merely early.
  • Using xs:string for everything. The schema then validates almost nothing. Use dates, decimals and enumerations.
  • Writing unanchored patterns. XSD patterns match the entire value. [0-9]+ does not mean "contains digits"; it means "is all digits". Conversely, do not add ^ and $, since they are treated as literal characters in XSD regular expressions.
  • Mixing up elementFormDefault. If the schema is unqualified but the document puts every element in the default namespace, local elements fail with errors saying they are not expected. Set it to qualified and keep the instance consistent.
  • Unprefixed names in namespaced XPath. As shown above, this returns an empty result without error.
  • Counting from 0. XPath positions start at 1.
  • Confusing //book[1] with (//book)[1]. The first selects every first child book per parent.
  • Comparing text without normalising whitespace. Pretty-printing adds newlines and spaces inside elements with mixed content. Use normalize-space().
  • Trusting floating-point sums in XPath 1.0. Money needs decimal arithmetic.
  • Letting the document pick its own schema. An xsi:schemaLocation in untrusted input is a hint you should not follow blindly.
  • Using a validator that is stricter or looser than your partner's. XSD 1.0 and 1.1 differ, and processors can differ in edge cases. Validate with the same version and, ideally, the same engine family as the receiver.
  • Hand-editing and forgetting encoding. If the declaration says UTF-8 but the file was saved in another encoding, the parser fails on the first non-ASCII character. Save and declare the same encoding.
  • Using attributes for everything or nothing. Attributes cannot repeat or nest, so a value that might become a list belongs in an element.

Security: XXE, entity expansion and schema poisoning

XML parsers support features designed for documents written by trusted authors. When you parse input from the internet, some of those features become attack surface. This section is the most important one for anyone running a service that accepts XML.

External entities (XXE)

A document can declare an entity that points to an external resource. If the parser resolves it, the content of a local file or a network response is substituted into the document, and may end up in your output or logs. I demonstrated this on a harmless test file. The document declared an entity that referenced a local text file and used it in the body:

<?xml version="1.0"?>
<!DOCTYPE r [<!ENTITY x SYSTEM "file:///path/to/secret.txt">]>
<r>&x;</r>

With lxml configured to resolve entities and load the DTD, the root's text came back as the file's contents, 'secret-data\n'. With a safe configuration the parser did not expand it, and the text was None:

unsafe = etree.XMLParser(resolve_entities=True, no_network=False, load_dtd=True)
safe   = etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False)

Real attacks use the same mechanism to read configuration files, probe internal network addresses (server-side request forgery), or exfiltrate data through URLs. The OWASP page on XML External Entity processing describes the attack and the defences for many platforms.

The defences are consistent across languages:

  1. Disable DTD processing when you do not need it. Most data exchange formats do not.
  2. Disable external entity and external DTD resolution.
  3. Disable network access from the parser.
  4. Prefer libraries that are safe by default, and check their documentation for each option. Defaults differ by library and version, and the safe settings are different knobs in each API, so read the documentation for the one you use.

Entity expansion (the "billion laughs" attack)

Entities can reference other entities, so a tiny document can expand to something enormous. I built a document of 389 bytes with eight levels of entities, each containing ten references to the previous level. In principle, that expands to about 100 million characters, from a document smaller than a tweet. Current libxml2 refuses it. Both a default lxml parser and one with resolve_entities=False raised:

Maximum entity amplification factor exceeded, see xmlCtxtSetMaxAmplification.

That guard exists in the libxml2 version I used, but older versions and other parsers may have no limit, or a different one. Do not assume. Check your parser's documentation for limits on entity depth, amplification and total size, and test a small bomb against your own stack in a safe environment. Also be careful with options that disable the protections: lxml's huge_tree option and similar switches in other libraries exist for legitimate large files, but they lift safety limits and should not be turned on for untrusted input.

Schema poisoning and external schemas

Schemas can import and include other schemas by URL, and instance documents can name their own schema with xsi:schemaLocation. If your validator fetches whatever the document asks for, an attacker can point it at a hostile schema or an internal address. A hostile schema can declare a very permissive grammar so that bad data validates, or can be crafted to make validation slow. Defences:

  • Load schemas from a trusted, local location. Bundle the XSD files with your application and pass them to the validator explicitly.
  • Ignore xsi:schemaLocation in untrusted input. Do not let the document choose its validator.
  • Block network access during validation, so imports cannot reach out.
  • Pin and review schema changes like any other code, since a schema is effectively part of your input validation logic.

Resource limits

Validation proves the document is acceptable in shape, but it does not stop a very large document from consuming memory. Set a maximum body size at the edge, limit nesting depth where your library allows, and apply timeouts. A schema with unbounded repetition accepts a document with millions of items just as happily as one with two.

Treat validated data as validated, not trusted

A valid document can still contain strings with script content, SQL fragments or path traversal sequences, because the schema only said they were strings. Escape or parameterise on output as for any other input, and remember XPath injection exists too: never build an XPath expression by concatenating user input into the string. Use variable binding if your library supports it, or strictly validate and escape the value first.

Performance on large documents

Validation and XPath both have costs that scale with document size, and the main decision is whether to load the whole tree or stream.

A DOM parser such as etree.fromstring builds the entire tree in memory. That is convenient, since XPath can then jump anywhere, but memory use grows with the document, usually to several times the file size. For streaming, lxml offers iterparse, which fires an event as each element completes so you can process it and throw it away.

To measure the difference, I generated a flat log of 200,000 small <e> elements (6,977,791 bytes) and counted the 200 elements whose lvl attribute equalled err, once with a full DOM and XPath, and once with iterparse. Each run was in its own process, and I recorded peak resident memory:

for ev, el in etree.iterparse(io.BytesIO(data), tag="e"):
    if el.get("lvl") == "err":
        count += 1
    el.clear()
    while el.getprevious() is not None:
        del el.getparent()[0]
Approach Matches Peak memory (KB) Process baseline (KB)
DOM plus XPath 200 177,504 59,924
iterparse with clearing 200 59,908 59,908

The streaming run stayed at the baseline, so its extra memory was negligible, while the DOM run added roughly 117 MB for a 7 MB file on this machine. These are measurements from one sample on one sandbox, so treat them as an illustration of the shape, not a benchmark. Timing told a more nuanced story. In an earlier timing run the DOM approach finished in about 0.19 seconds and iterparse in about 0.47 seconds, so streaming saved memory, not time. Choose it when the file is too big for memory, not because it is always faster.

Some practical rules follow:

  • Stream when files can be larger than memory, and remember to clear processed elements, otherwise iterparse quietly keeps the whole tree anyway.
  • Compile and reuse schemas. Parsing the XSD is expensive relative to validating one small document. Create the XMLSchema object once at startup, and reuse it. The Python example above does this.
  • Compile XPath expressions you run repeatedly. In lxml, etree.XPath("//item") returns a reusable callable.
  • Prefer specific paths over // on huge documents. /catalog/book visits far fewer nodes than //book, because // searches the whole tree.
  • Validate at the boundary once. Re-validating at every internal hop wastes time. Validate on input, then pass typed objects.
  • Validate streams if you must. Some libraries can validate while parsing. Check whether yours supports it, since validating a stream and then re-parsing it doubles the work.

For very large payloads, compression helps as it does for JSON. XML is verbose, repetitive text, which compresses well, and the principles in our JSON minification and payload size guide apply directly.

Accessibility and maintainability considerations

Schemas are documentation as much as they are validators, and readable ones help the people who consume them.

  • Use annotations. xs:annotation with xs:documentation lets you describe each element in plain language. Tools generate human-readable docs from them.
  • Choose descriptive names and consistent casing. OrderType and ItemType are clearer than T1 and T2.
  • Format schemas like code. Consistent indentation and ordering make reviews and diffs readable.
  • Write error messages for humans. When you reject a document, forward the validator's message together with the line number, and say in plain words what to fix. A message like "line 6: quantity 500 exceeds the maximum of 100" helps a non-developer correct a file. If your validator returns raw output, wrap it in a clearer sentence.
  • Keep documents accessible when they carry content. For XML that renders to users, such as XHTML or SVG, validity does not guarantee accessibility. Text alternatives, headings and language attributes still need checking by humans and tools.

Troubleshooting: symptom, cause, fix

Symptom: "Opening and ending tag mismatch" on a line that looks fine

Cause. The parser reports where it noticed the mismatch, which is at a closing tag. The real mistake is an earlier element that was never closed, or two tags closed in the wrong order.
Fix. Format the document so every level is indented, then look upward from the reported line for the element that has no matching end tag. In the example above, <order><item>One</order> is reported at column 25, but the missing </item> is earlier.

Symptom: "xmlParseEntityRef: no name" or "EntityRef: expecting ';'"

Cause. A bare ampersand in text or an attribute, such as Tom & Jerry or a URL with ?a=1&b=2.
Fix. Escape & as &amp;, < as &lt;, and use &quot; or single quotes for attributes containing double quotes. If the data comes from a template, escape it with an XML library and not string concatenation.

Symptom: "Extra content at the end of the document"

Cause. There is more than one root element, or stray text after the closing root tag.
Fix. Wrap the pieces in a single parent element, or remove the trailing content.

Symptom: "This element is not expected. Expected is ( ... )"

Cause. The content model was violated. Either the element is genuinely not allowed, it appears in the wrong order, or its namespace does not match.
Fix. Read the "Expected" list. If it names the element you wrote, you probably have the wrong order or the wrong namespace. If it names others, the element is out of place or missing a required predecessor. Remember the validator stops descending after a structural error, so fix this one and rerun.

Symptom: "The attribute 'x' is required but missing"

Cause. The schema declares use="required" and the document lacks it.
Fix. Add the attribute, or, if the schema is wrong, change it to optional or give it a default.

Symptom: "'value' is not a valid value of the atomic type 'xs:...'"

Cause. The text does not match the declared built-in type: a date in 14/03/2026 form, twelve where a decimal is needed, a decimal comma instead of a point.
Fix. Convert to the lexical form XSD expects. Dates are YYYY-MM-DD, decimals use a dot, booleans are true or false.

Symptom: "[facet 'pattern'] The value is not accepted by the pattern"

Cause. The value fails a regular expression facet, and XSD patterns must match the entire value.
Fix. Check case, length and separators against the pattern. Remember that there are no ^ or $ anchors in XSD patterns, and that some regex shortcuts from other flavours, such as lookaheads, are not supported.

Symptom: "No matching global declaration available for the validation root"

Cause. The root element's name or namespace does not match any global element in the schema. A missing or different xmlns is the usual culprit.
Fix. Compare the root element and its namespace URI with targetNamespace and the global element names, character for character. Check also that you loaded the right schema file.

Symptom: the XPath returns an empty list but the element is clearly there

Cause. Either the document uses a default namespace and your expression has no prefix, or you are using the wrong axis or case. Names are case-sensitive.
Fix. Bind a prefix to the namespace URI in your code and use it in the expression. As a quick diagnostic, try //*[local-name()='name']. If that returns the node, it is a namespace problem.

Symptom: the XPath returns the wrong element when using [1]

Cause. //x[1] selects the first x under every parent, not the first in the document.
Fix. Use (//x)[1].

Symptom: a text comparison with = never matches

Cause. Hidden whitespace, newlines or non-breaking spaces. Note that normalize-space() handles the ordinary whitespace characters, but a non-breaking space is not among them.
Fix. Use normalize-space() on the node, or contains() for partial matches, and for stubborn cases translate() to strip special characters.

Symptom: validation passes locally but the partner rejects the file

Cause. The partner uses a different schema version, a stricter processor, or extra business rules beyond the XSD.
Fix. Ask for the exact schema files and the validator they use, then validate with the same. Keep a library of sample files that pass on both sides.

Symptom: memory climbs while processing a big file with iterparse

Cause. The processed elements are never cleared, so the tree builds up anyway.
Fix. Call el.clear() after each element, and delete previous siblings from the parent as shown in the performance section.

Symptom: "Maximum entity amplification factor exceeded" or similar limit errors

Cause. The document uses nested entity definitions that the parser considers a possible expansion attack. Legitimate documents rarely need this.
Fix. Treat it as hostile unless the document comes from a source you trust. If a trusted data set truly needs it, restructure the data, and do not disable protections for public input.

Best practices and checklist

  • Parse, validate, then apply business rules, and report each stage's failures separately.
  • Ship an XSD for every format you publish, with annotations that explain each element.
  • Use specific built-in types and facets. Dates, decimals, enumerations and patterns catch errors for free.
  • Set elementFormDefault="qualified" and choose one targetNamespace per vocabulary.
  • Keep identifiers unique with xs:key or xs:unique, and use xs:keyref for references within a document.
  • Load schemas from trusted local files, never from the document being validated.
  • Disable DTDs, external entities and network access when parsing untrusted XML.
  • Compile and cache schemas and frequently used XPath expressions.
  • Stream large files, and clear elements as you go.
  • Bind namespaces explicitly in every XPath you run against namespaced documents.
  • Prefer attribute tests and specific paths over broad // searches.
  • Normalise whitespace before comparing text.
  • Do money arithmetic in decimals, not in XPath 1.0 floating point.
  • Version your schemas. Change namespaces or add version attributes when a change would break existing documents, and keep old versions available for old partners.
  • Test with both valid and invalid fixtures in CI, so a schema change that accidentally accepts bad data fails a test.

A short pre-release checklist for a new XML interface:

  1. Is every file checked for well-formedness first?
  2. Is there a schema, and does it use meaningful types and facets?
  3. Does the validator load the schema from a trusted location?
  4. Are DTD and external entity features switched off for untrusted input?
  5. Are there size and depth limits on incoming documents?
  6. Are cross-field business rules covered in code or Schematron?
  7. Do error messages reach the sender in a readable form?
  8. Are namespaces bound explicitly in all XPath code?
  9. Do automated tests include at least one invalid document per constraint?

Comparison: ways to check and query XML

Approach What it checks or does Strengths Weaknesses
Well-formedness check only Syntax Fast, universal, no schema needed Nothing about content
DTD Structure, basic attribute types Compact, legacy support No namespaces, few data types, security risks with entities
XSD Structure, rich types, constraints Widely supported, strong typing, tooling Verbose, cannot express all cross-field rules
RELAX NG Structure, patterns Simple model, readable compact syntax Less common in data exchange, weaker types without XSD datatypes
Schematron Business rules via XPath assertions Expresses cross-field logic Does not describe structure, needs an XPath 2.0 aware engine for some rules
Application code Anything Full flexibility Easy to forget cases, harder to share with partners
XPath assertions in tests Spot checks on output Quick, readable Not a complete grammar

In practice the strongest setups combine layers: a well-formedness check, an XSD for shape and types, Schematron or code for business rules, and XPath for extraction and tests.

FAQ

What is the difference between well-formed and valid XML?
Well-formed means the document follows the XML syntax rules, such as matching tags and one root. Valid means it also conforms to a schema, which defines allowed elements, order and types. A document can be well-formed without being valid, but never the other way round.

Do I need a schema to use XML?
No. Any well-formed XML can be parsed without one. A schema becomes valuable when other people or systems produce the data, because it states the contract and lets you reject bad input early.

Should I use XSD, DTD or RELAX NG?
For data exchange with partners, XSD is the safest default because of its types and tooling. DTDs are legacy and lack namespace support. RELAX NG is attractive for document formats and has a simple model. For business rules across fields, add Schematron or code.

Why does my XPath return nothing even though the element exists?
Most likely the document declares a default namespace. XPath names without a prefix match elements with no namespace, so you must bind a prefix to the namespace URI in your code and use it in the expression. Testing with local-name() quickly confirms the diagnosis.

Why do XPath positions start at 1?
That is how the language is defined, and it is different from most programming languages. //item[1] selects the first item, and //item[0] selects nothing.

What is the difference between //book[1] and (//book)[1]?
The first selects every book that is the first book child of its parent. The second takes all books in the document and returns only the first. Use parentheses when you want a single node.

Which XPath version should I use?
Use whichever your processor supports. Browsers, libxml2 and many libraries implement 1.0. If you need regular expressions, real data types, or sequences, look for a processor that supports XPath 2.0 or 3.1. The expressions in this guide that use only the core functions work in all versions.

Is XSD 1.1 widely supported?
Less so than 1.0. Many popular open-source tools implement 1.0 only, and the sandbox tool used here is one of them. If you depend on 1.1 features such as assertions, confirm that every consumer's validator supports them.

How do I protect against XXE?
Disable DTD processing and external entity resolution in your parser, block network access during parsing, and keep your XML library updated. Check the defaults of the exact library and version you use, because they differ.

Can a schema check that a total equals the sum of its items?
Not in XSD 1.0. XSD 1.1 assertions can express some such rules, but support varies. Use Schematron, application code or an XPath comparison for that kind of check.

Why does the validator report only some of my errors?
Validators often stop descending once a content model fails, so deeper errors stay hidden. Fix the reported problems and run again, and expect new messages to appear as earlier ones are resolved.

Is it safe to paste XML into an online validator?
Only if the tool runs locally. DevUtilX processes your input in the browser and does not upload it. Even so, avoid pasting credentials, tokens or personal data into any tool.

Can XPath modify a document?
No. XPath selects and computes values. To change or build documents, use XSLT, XQuery, or your language's DOM API with XPath locating the nodes.

Are attributes or elements better?
Use attributes for short, single-valued metadata about an element, such as ids and codes. Use elements for content, anything that may repeat, and anything that might need structure later.

How do I validate a very large file?
Compile the schema once and use a validator that supports streaming, or split the file into records and validate each. For queries, use an event-based parser such as iterparse rather than building the whole tree.

Conclusion

Correct XML comes in three layers: it parses, it matches its schema, and it makes sense for your application. Treat them as separate checks and you will find problems faster and report them more clearly. Learn the handful of XSD constructs that matter, namely types, facets, sequence, occurrence and namespaces, learn that XPath names need prefix bindings, and parse untrusted input with DTDs and external entities switched off.

To try these ideas on your own documents, paste them into the DevUtilX XML Validator and test your queries in the XPath Validator. Both run in your browser, so your data stays on your machine. For more on the surrounding craft, read our guides on XML formatting and structure and SVG and XML minification.

Further reading

Try the tools

Further reading

Related articles