Choosing Between XSD RELAX NG and Schematron for XML Checks
The same order checked three ways: XSD and RELAX NG passed a wrong total and a negative price, Schematron caught both, only RELAX NG took reordered children.

XML has three mainstream schema languages, and the choice is less about which is best than about which question you are asking. XSD answers "is this document shaped right and are the values the right type". RELAX NG answers the same with a simpler model of structure. Schematron answers "do the values relate to each other the way the business needs". They are not rivals so much as layers, and a pipeline that takes feeds from partners often wants two of them.
I ran the same small order through all three, because the differences are easiest to see on one document.
The order and the three rules
<order><qty>2</qty><price>9.50</price><total>19.00</total></order>The rules: an order with qty (a positive integer), price (a decimal) and total (a decimal), and the business rules that the price is positive and the total equals quantity times price. I wrote the shape rules in XSD and RELAX NG, and all the rules in Schematron, and tested four documents: a good one, one with a wrong total, one with a negative price and total, and one with the children in a different order.
xsd = etree.XMLSchema(etree.fromstring(b'''<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="order"><xs:complexType><xs:sequence>
<xs:element name="qty" type="xs:positiveInteger"/>
<xs:element name="price" type="xs:decimal"/>
<xs:element name="total" type="xs:decimal"/>
</xs:sequence></xs:complexType></xs:element></xs:schema>'''))
rng = etree.RelaxNG(etree.fromstring(b'''<element name="order" xmlns="http://relaxng.org/ns/structure/1.0"
datatypeLibrary="http://www.w3.org/2001/XMLSchema-datatypes">
<interleave>
<element name="qty"><data type="positiveInteger"/></element>
<element name="price"><data type="decimal"/></element>
<element name="total"><data type="decimal"/></element>
</interleave></element>'''))
sch = isoschematron.Schematron(etree.fromstring(b'''<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern><rule context="order">
<assert test="number(price) > 0">price must be positive</assert>
<assert test="(number(qty) * number(price) - number(total)) * (number(qty) * number(price) - number(total)) < 0.0001">total must equal qty times price</assert>
</rule></pattern></schema>'''), store_report=True)Results
ok | xsd True | rng True | schematron True
wrong total | xsd True | rng True | schematron False
negative | xsd True | rng True | schematron False
swapped order | xsd False | rng True(The labels are mine; the script printed the booleans.) Read it as a table. The wrong total passes XSD and RELAX NG. Both are checking that total is a decimal, and 20.00 is a decimal. Neither can compare it with another element. The negative price passes both too, because xs:decimal allows negatives. That one is fixable inside XSD with a derived type that has minExclusive of 0, so it is a matter of how much you put in the schema. The total is not fixable, because the rule involves three nodes. Schematron fails both, since an assertion is an XPath test and can look at anything in the document. Reordered children fail XSD (a sequence is an order) and pass RELAX NG, because interleave means any order.
A Schematron failure carries your message. The report that lxml stores is an SVRL document, and the relevant part reads:
<svrl:failed-assert test="(number(qty) * number(price) - number(total)) * (number(qty) * number(price) - number(total)) < 0.0001" location="/order"><svrl:text>total must equal qty times price</svrl:text></svrl:failed-assert>The location is an XPath to the failing node and the text is the sentence you wrote. For a data supplier, "total must equal qty times price" beats a type name. That is the real strength of Schematron: the rules are readable by a business analyst, and the messages are theirs.
One more thing the run taught me. My first Schematron test used abs(...) and failed with XSLTApplyError: Unregistered function. lxml's Schematron compiles to XSLT 1.0 and runs on libxslt, whose XPath is 1.0, which has no abs, min, max or regular expressions. I rewrote the tolerance as a squared difference. In a Schematron engine on top of XSLT 2.0 (Saxon) abs and matches work. If you share a Schematron file between engines, write the XPath 1.0 subset.
How the three compare
| XSD 1.0 | RELAX NG | Schematron | |
|---|---|---|---|
| Describes | shape and types | shape and types | rules over the tree |
| Syntax | XML, verbose | XML, and a compact text form | XML with XPath |
| Element order | sequence, choice, all | interleave, choice, group | not its business |
| Types | rich built-in library | uses XSD's datatypes or its own | XPath expressions |
| Cross-field rules | no (1.1 has assertions) | no | yes |
| Error messages | a type or a facet | generic | yours |
| Tooling | everywhere | Jing, libxml2, many | libxslt route, Saxon, Oxygen |
| Schema inference | many tools | trang | none |
XSD is the lingua franca. Partners publish XSDs, databases register them, code generators read them, and every language has a validator. If a standard defines the format, it will give you an XSD and you will use it. XSD's weaknesses are verbosity, a rigid model of order and an awkward way to say "these elements in any order", which pushes people to unordered xs:all with its own restrictions.
RELAX NG is the easiest to read and write, and it models structure more naturally: choices, mixed content and any-order groups are first-class. It has a compact syntax (.rnc) that is far shorter than the XML form. Its weakness is adoption. Fewer partners hand you one, and fewer tools read it, although libxml2, Jing and the document-publishing world use it.
Schematron is a different kind of language. It is not about grammar at all. It is a list of assertions, each an XPath expression with a message, grouped by the context they apply to. It cannot say that a document has the right elements in the right order, and it does not need to, because the other two do.
A sensible stack
The pattern that has worked for me with partner feeds has two layers:
Well-formedness, then XSD. Reject files that are broken or have the wrong shape, with the partner's own schema when they publish one. Exit codes and the first few errors go into a report to the sender.
Schematron or code for business rules. Cross-field checks, uniqueness, lookups. Schematron if the rules should be written and reviewed by non-programmers; code if they involve a database, which Schematron cannot reach.
A third layer is database constraints, which are the last line and the only one that holds for data that arrives by other routes.
If you only get one, take XSD, for the ecosystem. If you write your own format and nobody dictates the language, RELAX NG will give you a smaller schema and better treatment of mixed content, at the price of a smaller toolchain.
Practical notes
Keep each layer's results separate in the report: "3 files failed shape validation, 2 failed business rules". Merging them hides which team has to act. Version the Schematron file with the XSD, because a rule such as the total check depends on the elements the XSD defines. And keep a set of known-bad documents, one per rule, as regression tests. Each of the four above is a ready-made test: a good order, a wrong total, a negative price and a reordered order, with the expected result under each language.
For the writing of the XSD, see writing a basic XSD schema; for the execution, validating XML in Python with lxml and XSD and validating XML against XSD with xmllint. The overall guide is validating XML against an XSD schema.
The standards are ISO Schematron, RELAX NG and XML Schema, and lxml's support is described in its validation guide. The XPath tester is handy for developing assertion tests.
When none of the three is the right check
The rule needs data from outside the document. Is the customer known, is the SKU in stock, is the date after the last close? That needs a database lookup in code, after the document is valid.
The format is JSON or CSV. XML schema languages do not apply. JSON has JSON Schema, and CSV has column-level checks in the loader.
The check is statistical. "The null rate of this field jumped from 1 percent to 40 percent" is a monitoring rule on the batch, not a validation of a document.
The sender will not change anything. If the feed is what it is, a rejecting validator only stops your own load. Quarantine bad records, load the good ones and send the sender the report.


