XSchematron 0.2.2
dotnet add package XSchematron --version 0.2.2
NuGet\Install-Package XSchematron -Version 0.2.2
<PackageReference Include="XSchematron" Version="0.2.2" />
<PackageVersion Include="XSchematron" Version="0.2.2" />
<PackageReference Include="XSchematron" />
paket add XSchematron --version 0.2.2
#r "nuget: XSchematron, 0.2.2"
#:package XSchematron@0.2.2
#addin nuget:?package=XSchematron&version=0.2.2
#tool nuget:?package=XSchematron&version=0.2.2
XSchematron
A .NET library that validates XML documents against ISO Schematron rule sets — the business-rule layer that XSD can't express ("if the VAT category is X, the rate must be Y").
XSchematron parses a .sch file into an object model and walks pattern → rule → assert/report,
evaluating each rule's XPath directly. There is no XSLT compilation step and no SVRL
round-trip, so you don't need an XSLT 2.0 processor — which matters, because .NET's built-in one
is XSLT 1.0 only, and that is not enough for the queryBinding="xslt2" rule sets used throughout
e-invoicing.
Looking for a command line rather than an API? Install
XSchematron.Cli, the xschematron dotnet
tool built on this library.
Install
dotnet add package XSchematron
Targets net8.0 and net10.0. The only runtime dependency is
XPath2.Net (MS-PL).
You supply the rule files. This package ships code only — no
.schfiles are included. Point it at the rule sets you need (CEN EN 16931, Peppol BIS Billing 3.0, the French BR-FR-CTC and Factur-X rules, or your own) at runtime.
Quickstart
using XSchematron.Evaluation;
using XSchematron.Model;
// Load one or more Schematron rule files...
var schemas = new[] { "CEN-EN16931-UBL.sch", "PEPPOL-EN16931-UBL.sch" }
.Select(path => SchematronSchema.Load(path));
// ...and validate a document against all of them at once.
var validator = new SchematronValidator(schemas);
using var xml = File.OpenRead("invoice.xml");
var result = validator.Validate(xml);
foreach (var message in result.Messages)
{
Console.WriteLine($"[{message.Severity}] {message.RuleId} at {message.XPathContext}");
Console.WriteLine($" {message.Message}");
}
Console.WriteLine(result.IsValid ? "valid" : "invalid");
Thread safety and reuse
Hold onto your parsed schemas. A SchematronSchema is immutable, and it is what the library
caches against: compiled XPath, interpreted <xsl:function> bodies, and document('…') code lists
are all keyed to the schema instance. Parse each rule set once, keep it for the life of the process,
and build validators around it freely — constructing a SchematronValidator from already-parsed
schemas is cheap. Re-parsing a rule set per document throws that cache away and roughly doubles the
cost of every validation.
SchematronValidator is thread-safe. Any number of threads may call Validate on one instance
at the same time. Schemas may also be parsed on one thread while others validate, which is what a
server does when it accepts an uploaded rule set while requests are in flight. Nothing needs to be
locked or pooled by the caller.
// Parse once, at startup.
private static readonly SchematronSchema[] RuleSets =
[SchematronSchema.Load("CEN-EN16931-UBL.sch"), SchematronSchema.Load("PEPPOL-EN16931-UBL.sch")];
// Then, per request, from any thread:
var result = new SchematronValidator(RuleSets).Validate(xmlStream);
The cache lives exactly as long as the schema does. A rule set held in a static field or a DI singleton stays warm for the life of the process; one parsed to serve a single request is collected along with it, so accepting user-supplied schemas does not grow memory without bound.
API
Loading a schema
SchematronSchema is the parsed, in-memory form of a <schema> document — its namespace table,
phases, schema-scoped <let> variables, and patterns.
| Method | Use |
|---|---|
SchematronSchema.Load(string path, SchematronLoadOptions? options = null) |
Parse a .sch file from disk |
SchematronSchema.Load(Stream stream, string sourceName, SchematronLoadOptions? options = null) |
Parse from a stream; sourceName is what messages and errors are attributed to |
SchematronSchema.Parse(string xml, string sourceName = "<memory>", SchematronLoadOptions? options = null) |
Parse from an in-memory string |
SchematronLoadOptions carries two settings:
ValidateCustomFunctions(defaulttrue) — fail fast at load time if the schema declares a custom<function>with no matching native port. Set tofalsefor tooling that only reads a schema's structure; evaluation will still fail later if a rule actually calls the unported function.OnUnrecognizedElement— a callback invoked once per element the parser skips (<title>,<p>,<diagnostics>, …), so you can observe what is being ignored instead of reading the source XML by hand.
var schema = SchematronSchema.Load(path, new SchematronLoadOptions
{
ValidateCustomFunctions = false,
OnUnrecognizedElement = name => Console.WriteLine($"skipped <{name}>"),
});
Validating
public ValidationResult Validate(Stream xmlStream, ValidationOptions? options = null);
public ValidationResult Validate(XPathDocument document, ValidationOptions? options = null);
Prefer the stream overload. It loads the document with line-info tracking enabled, so every
message carries the matched node's position in the source document. The XPathDocument overload
validates a document you already have, but whether line info is available depends on how that
document was built — not on anything the validator controls.
Restrict evaluation to a single phase with ValidationOptions:
var result = validator.Validate(xml, new ValidationOptions { PhaseId = "codelist_phase" });
When PhaseId is null (the default), every pattern in every schema runs — the ISO Schematron
#ALL default.
Results
ValidationResult exposes:
| Member | Meaning |
|---|---|
Messages |
Every message across every schema, in evaluation order |
BySchema |
The same messages grouped by the .sch file that produced them |
IsValid |
true when no message has Error severity |
Each ValidationMessage is a record with:
| Property | Meaning |
|---|---|
SchemaSource |
The .sch file this message came from |
RuleId |
The firing assert/report's id, e.g. "BR-52" (nullable) |
PatternId |
The enclosing pattern's id (nullable) |
Kind |
AssertionKind.Assert or AssertionKind.Report |
Severity |
Error, Warning, or Info — see the mapping below |
Flag |
The raw flag attribute value, preserved for your own policy (nullable) |
Message |
The assert/report's message text |
XPathContext |
Absolute path of the node that matched the rule |
LineNumber |
Line of the assert/report declaration in the schema file (nullable) |
DocumentLineNumber / DocumentLinePosition |
Position of the matched node in the validated document (nullable; always populated by the Stream overload) |
Exceptions
| Exception | When |
|---|---|
SchematronParseException |
The .sch is not well-formed, or breaks a structural requirement (e.g. a <rule> with no context) |
UnsupportedSchematronFunctionException |
The schema declares a custom <function> with no native port |
UnsupportedXPathFeatureException |
A rule uses an XPath construct the evaluator does not support |
DocumentResolutionException |
A rule's document('…') names a missing file, or one outside the schema's directory |
SchematronValidator's constructor throws ArgumentException if given no schemas.
Supported Schematron features
| Feature | Status |
|---|---|
<pattern> / <rule> / <assert> / <report> |
Supported |
<let> variables at schema, pattern, and rule scope |
Supported, including chaining (a later <let> may reference an earlier one) and per-matched-node evaluation of rule-scoped lets |
<phase> / <active> |
Supported, opt-in via ValidationOptions.PhaseId; all patterns run by default (ISO #ALL) |
<ns> prefix declarations |
Supported |
flag attribute (fatal, warning) |
Supported, mapped to severities |
XPath 2.0 in context/test/let |
Supported via XPath2.Net |
XSLT match-pattern semantics for rule/@context |
Supported — a relative context matches nodes anywhere in the document |
Custom XSLT <function> blocks |
Supported — natively ported ones below, plus any declaration whose body is pure XPath; anything else fails fast |
<value-of> / <name> message interpolation |
Supported, resolved against the node that matched |
document('…') |
Supported, resolved relative to the schema's own directory and confined to it |
<sch:include>, abstract patterns / is-a |
Not supported — rejected at load with a message naming the construct, never silently skipped |
<extends> rule inheritance |
Not supported — reported through OnUnrecognizedElement |
| SVRL output | Not supported — results use the library's own model |
Severity mapping
| Source | Severity |
|---|---|
<assert> fails |
Error |
<assert flag="fatal"> fails |
Error |
<assert flag="warning"> fails |
Warning |
<report> fires |
Info (does not make a document invalid) |
The raw flag value stays on ValidationMessage.Flag if you want a different policy.
Custom <function> support
Real-world rule sets embed custom XSLT functions — national identifier checksums, format checks. XSchematron handles them two ways.
Interpreted bodies. A declaration whose body is pure XPath dressed as XSLT — <xsl:param>s,
<xsl:variable select="…"/> bindings, and a single <xsl:sequence select="…"/> — is evaluated
directly, with no porting needed. That covers all 19 custom:* functions of the French FNFE-MPE
rule sets. A body using XSLT control flow (<xsl:choose>, <xsl:for-each>, recursion) is
deliberately not approximated.
Native ports. 19 functions are implemented in C#, and take precedence over any declared body:
- Peppol (
utilsnamespace):u:gln,u:slack,u:mod11,u:mod97-0208,u:checkCodiceIPA,u:checkCF,u:checkCF16,u:checkPIVAseIT,u:checkPIVA,u:addPIVA,u:abn,u:TinVerification,u:checkSEOrgnr - France:
custom:check-siret-siren-coherence,custom:is-valid-decimal-19-2,-19-4,-19-6,-19-6-positive,custom:is-valid-percent-4-2-positive
Because a port outranks a schema's own body, only functions declared identically by every rule set
that uses them are ported. Several French functions are not: custom:is-valid-date-format has three
different bodies across the FNFE-MPE rule sets, and porting it would apply one rule set's meaning to
the others.
One name can mean two things. Calls are dispatched to the body belonging to the schema being evaluated, so loading rule sets that disagree about a function into one validator is safe.
If you load a .sch that declares a <function> with neither a port nor an interpretable body,
loading throws UnsupportedSchematronFunctionException immediately — it never silently skips the
rule or reports a wrong result. To port a function, implement ISchematronCustomFunction and
register it in CustomFunctionRegistry; contributions of ports for other national rule sets are
welcome.
Why not compile to XSLT?
The traditional approach runs a .sch file through the ISO skeleton stylesheets to produce a
validating XSLT, then parses its SVRL output. That's standards-complete but needs a full XSLT 2.0
processor. On .NET the only fully supported one is SaxonCS, which ships only in the paid
Saxon-EE tier — Saxon-HE never made the jump to .NET Core. Walking the rules directly and
evaluating XPath 2.0 through the MS-PL-licensed XPath2.Net sidesteps that entirely, at the cost of
owning the Schematron semantics ourselves — which is why the feature table above is explicit about
the gaps.
The engine is tested against 13 real, unmodified rule sets totalling over 6,700 assertions: CEN EN 16931 in both UBL and CII bindings, Peppol BIS Billing 3.0 and its SBDH envelope rules, and the full French CTC / Factur-X stack published by FNFE-MPE for the September 2026 mandate.
Licensing
MIT — see LICENSE. The runtime dependency
XPath2.Net is MS-PL licensed; MS-PL is permissive and imposes no obligations on code that merely
consumes it. Full third-party attribution is in NOTICE.md, included in this package and
in the repository.
Links
- Source, issues, and contributing: https://github.com/WimSuenens/XML
- Command-line tool: XSchematron.Cli
- Runnable example: samples/XSchematron.Samples
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.