Step 1

The smallest useful schema

A schema is patterns; a pattern is rules; a rule is a context plus assertions.

rules.sch

<schema xmlns="http://purl.oclc.org/dsdl/schematron">
  <pattern>
    <rule context="invoice">
      <assert test="total">An invoice must have a total.</assert>
    </rule>
  </pattern>
</schema>

@context is an XPath expression selecting the nodes the rule applies to. @test is an XPath expression evaluated with each such node as the context node.

schematron --schema rules.sch data.xml

Step 2

assert versus report

They are opposites, and the direction catches everyone once.

  • assert fires when its test is false — "this must be true".
  • report fires when its test is true — "tell me if this happens".
<rule context="invoice">
  <assert test="total">An invoice must have a total.</assert>
  <report test="count(line) > 100">This invoice has an unusual number of lines.</report>
</rule>

An assert that fires is a failure. A report that fires is an observation. Both land in the report; only failed asserts make a document invalid.

Step 3

Messages that say what went wrong

Assertion text is mixed content, so it can interpolate the document with value-of and name the offending node with name:

<assert test="number(@qty) > 0">
  Quantity must be positive, but <name/> has <value-of select="@qty"/>.
</assert>

Longer explanations belong in a diagnostic, referenced by @diagnostics, so the message stays short and the help stays available:

<assert test="number(@qty) > 0" diagnostics="qty-help">
  Quantity must be positive, but is <value-of select="@qty"/>.
</assert>

<diagnostics>
  <diagnostic id="qty-help">
    Quantity is the number of units ordered. It must be a positive number.
  </diagnostic>
</diagnostics>

Step 4

First matching rule wins

Use it deliberately, to write an "otherwise" branch:

<pattern id="lines">
  <rule context="line[@type='discount']">
    <assert test="number(@amount) < 0">A discount must be negative.</assert>
  </rule>
  <rule context="line">
    <assert test="number(@amount) >= 0">A normal line must not be negative.</assert>
  </rule>
</pattern>

And watch for it shadowing a rule you meant to run:

<pattern>
  <rule context="*">…</rule>
  <rule context="invoice">…</rule>  <!-- never runs -->
</pattern>

To apply independent checks to the same node, put them in separate patterns. Each pattern gets its own pass over the document.

Step 5

Namespaces

XPath 1.0 has no default namespace. An unprefixed name in a test matches elements in no namespace — so against a namespaced document, every context fails to match and nothing fires.

<schema xmlns="http://purl.oclc.org/dsdl/schematron">
  <ns prefix="inv" uri="http://example.com/invoice"/>
  <pattern>
    <rule context="inv:invoice">
      <assert test="inv:total">An invoice must have a total.</assert>
    </rule>
  </pattern>
</schema>

Declare the prefix with <ns> and use it everywhere — in contexts, in tests, in select attributes.

Steps 6–8

Variables, diagnostics, phases

let binds a name to an expression, at schema, pattern, or rule scope. A rule-scope let is evaluated with the rule's context node:

<let name="tax-rate" value="0.2"/>

<rule context="invoice">
  <let name="lines-total" value="sum(line/@amount)"/>
  <assert test="number(total) = $lines-total * (1 + $tax-rate)">
    Total is <value-of select="total"/> but the lines plus tax come to
    <value-of select="$lines-total * (1 + $tax-rate)"/>.
  </assert>
</rule>

phase selects which patterns run, so one schema can serve a quick structural check and a full audit:

<schema xmlns="http://purl.oclc.org/dsdl/schematron" defaultPhase="basic">
  <phase id="basic">
    <active pattern="structure"/>
  </phase>
  <phase id="strict">
    <active pattern="structure"/>
    <active pattern="lines"/>
    <active pattern="totals"/>
  </phase>
  …
</schema>
schematron -s rules.sch --list-phases
schematron -s rules.sch --phase strict data.xml
schematron -s rules.sch --phase '#ALL' data.xml

Step 13

= is not eq

In XPath 1.0, comparing a node-set with = is an existential comparison: it is true if any node in the set compares true. That is usually what you want in a Schematron test — and occasionally exactly what you did not want.

Negation does not distribute over an existential comparison.

not(line/@type = 'discount') means "no line is a discount". line/@type != 'discount' means "some line is not a discount". These are different tests, and both are sometimes correct.

Step 17

When a schema seems to do nothing

Two causes account for nearly all of them, and the tool can tell them apart:

  1. A missing ns prefix. Every context fails to match, so no rule ever fires. --verbose shows no fired rules at all.
  2. An earlier rule in the same pattern claimed the nodes. --explain marks every rule after the first in a pattern with a reminder that it only sees nodes no earlier rule claimed.
schematron -s rules.sch --lint

More on diagnosing a quiet schema →

Step 18

Where to go next

The repo ships a worked schema — examples/invoice.sch with invoice-good.xml and invoice-bad.xml — used throughout the full tutorial and the CLI tests. Clone it and run it:

git clone https://github.com/schematron-rust/schematron-rust
cd schematron-rust
cargo run -- -s examples/invoice.sch -p strict -v examples/invoice-bad.xml

The full eighteen-step tutorial Use the library

← Everything else is in the repo