Skip to content
datahacks.dev
XML

Writing a Basic XSD Schema That Rejects Bad Orders

Build an XSD for an orders file step by step: sequence, pattern, enumeration, optional elements, required attributes and a decimal with attributes.

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

An XSD, short for XML Schema Definition, is a file that says what a valid XML document looks like. It lists the elements that may appear, in what order and how many times, which attributes they carry, and what each value must look like. A validator reads the schema and a document and tells you whether the document fits. You write a schema when other people or other systems send you XML and you want to find out about a bad file at the door, not in the middle of a load.

This article builds a small one from nothing, one feature at a time, and shows what each feature rejects.

The document we want to describe

good.xmlXML
<?xml version="1.0" encoding="UTF-8"?>
<orders>
  <order id="1"><customer>Ana Lee</customer><status>paid</status><item sku="A-100" qty="2">9.50</item><item sku="B-220">24.00</item></order>
  <order id="2"><customer>Bo Chen</customer><status>new</status><note>gift</note><item sku="A-100" qty="1">9.50</item></order>
</orders>

The rules in words: an orders root holds one or more order elements. Each order has an integer id attribute (required), then a customer, a status that is one of new, paid or shipped, an optional note, and one or more items. An item holds a price (a decimal) as its text, a sku attribute in the shape letter, hyphen, three digits, and an optional positive integer qty that defaults to 1.

Step 1: the skeleton

Every schema is an xs:schema element in the XML Schema namespace. Each element you want to allow is declared with xs:element, and an element with children gets an anonymous xs:complexType containing an xs:sequence (children in this order), xs:choice (one of) or xs:all (any order, each at most once).

XML
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified">
  <xs:element name="orders">
    <xs:complexType>
      <xs:sequence>
        <xs:element name="order" maxOccurs="unbounded">
          ...

maxOccurs="unbounded" means one or more, because the default for both minOccurs and maxOccurs is 1. A required element appears exactly once unless you say otherwise. Only the elements declared at the top level of the schema can be document roots, which is why orders is declared there and order is nested inside it: a document whose root is order is rejected.

Text
e_root.xml:2: element order: Schemas validity error : Element 'order': No matching global declaration available for the validation root.

Step 2: value types, named once

Text values are described by simple types. Built-in ones cover the common cases: xs:string, xs:decimal, xs:integer, xs:positiveInteger, xs:date, xs:boolean. When a value has a shape of its own, derive a named type from a built-in one by restriction and refer to it by name:

XML
<xs:simpleType name="Sku">
  <xs:restriction base="xs:string">
    <xs:pattern value="[A-Z]-[0-9]{3}"/>
  </xs:restriction>
</xs:simpleType>
<xs:simpleType name="Status">
  <xs:restriction base="xs:string">
    <xs:enumeration value="new"/>
    <xs:enumeration value="paid"/>
    <xs:enumeration value="shipped"/>
  </xs:restriction>
</xs:simpleType>

A pattern is a regular expression that must match the whole value; there are no anchors in XSD patterns, because the match is always complete. Its dialect is XSD's own (close to Perl but without lookahead or backreferences). An enumeration lists the allowed values, compared exactly, so PAID is not paid:

Text
e_enum.xml:3: element status: Schemas validity error : Element 'status': [facet 'enumeration'] The value 'PAID' is not an element of the set {'new', 'paid', 'shipped'}.

Other restriction facets you will use: minLength, maxLength, minInclusive, maxInclusive, totalDigits, fractionDigits. A price with at most two decimals is xs:decimal restricted with fractionDigits of 2.

Step 3: the order, with an optional element

XML
<xs:element name="order" maxOccurs="unbounded">
  <xs:complexType>
    <xs:sequence>
      <xs:element name="customer" type="xs:string"/>
      <xs:element name="status" type="Status"/>
      <xs:element name="note" type="xs:string" minOccurs="0"/>
      <xs:element name="item" maxOccurs="unbounded">
        ...
      </xs:element>
    </xs:sequence>
    <xs:attribute name="id" type="xs:integer" use="required"/>
  </xs:complexType>
</xs:element>

Three things happen here. The sequence fixes the order, so an order that lists status before customer is rejected even though every element is present:

Text
e_order.xml:3: element status: Schemas validity error : Element 'status': This element is not expected. Expected is ( customer ).

minOccurs="0" makes note optional. An order with no item fails because at least one is required:

Text
e_missing.xml:3: element order: Schemas validity error : Element 'order': Missing child element(s). Expected is one of ( note, item ).

The message lists note and item because the validator, having seen customer and status, is willing to accept either next. And the attribute id is declared after the sequence, as attributes always are, with use="required"; without it the attribute is optional.

Text
e_attr.xml:3: element order: Schemas validity error : Element 'order': The attribute 'id' is required but missing.

Step 4: an element with text and attributes

The item is text (the price) with attributes. That combination has its own construct: xs:simpleContent with an xs:extension that adds the attributes to a simple base type.

XML
<xs:element name="item" maxOccurs="unbounded">
  <xs:complexType>
    <xs:simpleContent>
      <xs:extension base="xs:decimal">
        <xs:attribute name="sku" type="Sku" use="required"/>
        <xs:attribute name="qty" type="xs:positiveInteger" default="1"/>
      </xs:extension>
    </xs:simpleContent>
  </xs:complexType>
</xs:element>

Now 9,50 (comma decimal separator) is rejected because it is not an xs:decimal, a zero quantity is rejected because zero is not positive, and a lowercase SKU fails the pattern:

Text
e_dec.xml:3: element item: Schemas validity error : Element 'item': '9,50' is not a valid value of the atomic type 'xs:decimal'.
e_qty.xml:3: element item: Schemas validity error : Element 'item', attribute 'qty': '0' is not a valid value of the atomic type 'xs:positiveInteger'.
e_pat.xml:3: element item: Schemas validity error : Element 'item', attribute 'sku': [facet 'pattern'] The value 'a-1' is not accepted by the pattern '[A-Z]-[0-9]{3}'.

The default="1" on qty deserves a word. It is a statement about the meaning of a missing attribute, and a validator that only reports valid or invalid does not add it to your document. Some tools (lxml with attribute_defaults=True when reading with a schema) do, and you cannot rely on it. If your load needs a quantity, write the default in the load code as well, and treat the schema's default as documentation.

The whole schema

Put together it is about 40 lines, shown in full in the companion file shop.xsd. The names are declared in one place, the value rules sit in named types, and the structure reads top to bottom like the document. Two style choices are worth making early. Name the simple types (Sku, Status) rather than nesting a restriction inside every element, so the same rule is reused and the error messages can be traced to a name. And set elementFormDefault="qualified" once at the top, whether or not you use a namespace today, so a later move to a target namespace does not change which elements are qualified.

What XSD 1.0 cannot say

A schema describes the shape and the form of values, one node at a time. It cannot express "the total equals quantity times price", "the end date is after the start date", "this element is required when that attribute equals x", or "the SKU exists in the product table". XSD 1.1 added assertions that can say some of this, but libxml2 (and therefore xmllint, lxml, PHP and many others) implements 1.0, so a schema that uses 1.1 features will not compile there. The usual answer is a second layer of rules in Schematron or in code, covered in choosing between XSD, RELAX NG and Schematron.

Running it

Validate with xmllint --noout --schema shop.xsd good.xml, which printed good.xml validates and exit code 0. Failures exit with 3. The two tools and their differences are in validating XML against XSD with xmllint and validating XML in Python with lxml and XSD, and the messages you will meet are decoded in common XSD validation errors explained. If you already have sample files and want a first draft, generating an XSD from sample XML shows what inference gets right and wrong. The overall picture is in validating XML against an XSD schema.

The schema language is specified in XML Schema Part 0: Primer, which is the readable entry point, and the regular expression dialect for pattern is in Part 2, Appendix F. To try an XPath against a document you are about to describe, use the XPath tester.

When XSD is too blunt for the rule

The rule involves two values. A total, a date range or a conditional requirement needs Schematron or code. Use XSD for shape and let a second pass check relationships.

The order of children is not meant to matter. A sequence rejects valid-in-spirit documents that list the same elements in another order. xs:all allows any order but each element only once; RELAX NG's interleave handles any order and repetition more gracefully.

The vocabulary changes often. A strict schema that is edited weekly becomes a bottleneck. Validate the stable core strictly and allow extension with xs:any where the sender may add elements.

The values come from a table. Enumerating 3,000 country or product codes in a schema is a maintenance burden. Check against the table in the load instead.

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.