Schemurai
A JSON Schema validator for Ruby with reusable compiled schemas and detailed
validation errors. It supports Draft 7, Draft 2019-09, and Draft 2020-12 and
covers the required cases in the official JSON-Schema-Test-Suite, as well as
the applicable optional tests for numeric
precision, ECMA-262 regular expressions, content validation, anchors, and dynamic
references. The dialect is selected from the schema's $schema URI; schemas that
omit $schema use Draft 7 for compatibility.
Choose the default Ruby evaluator, the Ruby VM backend, or the optional experimental Rust native backend. See backend selection for setup and requirements.
Schemurai ships RBS declarations for its public API in sig/schemurai.rbs.
require "schemurai"
schema = {
"$schema" => "https://json-schema.org/draft/2020-12/schema",
"type" => "object",
"properties" => {
"order_id" => { "type" => "string", "pattern" => "^ord_[0-9]{6}$" },
"customer" => { "$ref" => "#/$defs/customer" },
"items" => {
"type" => "array",
"minItems" => 1,
"items" => { "$ref" => "#/$defs/line_item" }
},
"created_at" => { "type" => "string", "format" => "date-time" }
},
"required" => %w[order_id customer items created_at],
"additionalProperties" => false,
"$defs" => {
"customer" => {
"type" => "object",
"properties" => {
"id" => { "type" => "integer", "minimum" => 1 },
"name" => { "type" => "string", "minLength" => 1 },
"tier" => { "enum" => %w[standard premium] }
},
"required" => %w[id name],
"additionalProperties" => false
},
"line_item" => {
"type" => "object",
"properties" => {
"sku" => { "type" => "string", "pattern" => "^SKU-[A-Z0-9]{8}$" },
"quantity" => { "type" => "integer", "minimum" => 1 },
"unit_price" => { "type" => "number", "minimum" => 0 }
},
"required" => %w[sku quantity unit_price],
"additionalProperties" => false
}
}
}
order = {
"order_id" => "ord_123456",
"customer" => { "id" => 42, "name" => "Ada Lovelace", "tier" => "premium" },
"items" => [
{ "sku" => "SKU-ABC12345", "quantity" => 2, "unit_price" => 19.95 }
],
"created_at" => "2026-08-31T10:15:00+09:00"
}
validator = Schemurai.compile(schema, format: true)
validator.valid?(order) # => true
invalid_order = order.merge(
"items" => [order.fetch("items").first.merge("quantity" => 0)]
)
result = validator.validate(invalid_order)
result.valid? # => false
result.errors.each do |error|
puts "#{error.instance_path}: #{error.message}"
endFor repeated validation, compile the schema once and reuse the validator. A schema registry owns the compiled resource graph, so schemas compiled by the same registry also share their compiled external references.
registry = Schemurai::SchemaRegistry.new(
schemas: {
"https://example.test/positive" => { "type" => "integer", "minimum" => 1 }
}
)
validator = registry.compile({"$ref" => "https://example.test/positive"})
validator.valid?(1) # => true
validator.valid?(0) # => false
validator.validate(0).errors # detailed errors, without recompiling the schemaSchemurai.compile is a convenience for compiling a standalone
validator. Repeatedly compiling the same schema object with one registry reuses
its compiled schema graph. Schemurai.validate and .valid? continue
to accept raw JSON-like schemas and perform compilation internally.
Schema inputs are checked recursively before compilation. They must use the
built-in JSON-shaped Ruby classes described in
docs/compatibility-domain.md; rejected values
are never retained by a registry.
To resolve external references, pass a mapping of URIs to schemas using
schemas:.
Schemurai.valid?(
{ "$ref" => "https://example.test/positive" },
3,
schemas: { "https://example.test/positive" => { "type" => "integer", "minimum" => 1 } }
)Draft 2019-09 and Draft 2020-12 support their dialect-specific keywords, including
$recursiveRef / $dynamicRef, $defs, dependentSchemas, dependentRequired,
minContains, maxContains, and the unevaluated* applicators. Enable optional
validation for contentEncoding and contentMediaType with content: true.
Enable optional format assertions with format: true; support for each format is
listed separately below.
Validation errors
result = Schemurai.validate(
{ "$ref" => "https://example.test/positive" },
-1,
schemas: { "https://example.test/positive" => { "type" => "integer", "minimum" => 1 } }
)
validation_error = result.errors[0]
p validation_error.keyword # => "minimum"
p validation_error.instance_path # => ""
p validation_error.schema_path # => "/$ref/minimum"
p validation_error.message # => "number must be greater than or equal to 1"Result#errors contains Schemurai::ValidationError objects.
Check the following attributes to investigate schema errors:
-
keywordidentifies the failed JSON Schema keyword. Schemurai usesfalseSchemafor a booleanfalseschema, which has no keyword of its own. -
instance_pathis a JSON Pointer to the value that failed validation. -
schema_pathis a JSON Pointer to the schema location that produced the error. -
messageis a human-readableStringdescribing the failure.
An empty path points to the instance or schema root. Error order is stable.
The presence and type of message are part of the public API, but its exact
wording is not. Use keyword, instance_path, and schema_path, rather than
matching message, when handling errors programmatically. ValidationError#to_h
returns these four attributes as a Hash.
Thread / Ractor support
To share a registry between threads or Ractors, finish registering schemas and make the registry shareable first. This eagerly compiles every registered schema, resolves all references, and makes the registry deeply immutable.
registry = Schemurai::SchemaRegistry.new(
schemas: {
"https://example.test/positive" => { "type" => "integer", "minimum" => 1 },
"https://example.test/value" => { "$ref" => "https://example.test/positive" }
},
# enable virtual machine backend that is faster for iterative validation.
backend: :vm
)
registry.make_shareable
# Each thread or Ractor creates and owns its validator.
validator = registry.validator_for("https://example.test/value")make_shareable calls Ractor.make_shareable internally. It raises a
ResolutionError if a reference cannot be resolved. After it returns,
validator_for is read-only and may be called concurrently, while compile is
no longer available. A Validator contains per-validation mutable state and
must not be shared between threads or Ractors. With the VM and native backends,
the registry compiles and shares its bytecode before this transition. The native
backend also prepares shared native instruction tables for all four combinations
of content and format; validator_for does not recompile them in each Ractor.
Native regex search caches and Ruby runtime helper state belong to each
validator. Preparing these tables adds time and memory to make_shareable.
Meta-schema validation
Meta-schema validation is opt-in so ordinary compilation does not pay its
additional time and memory cost. Set validate_schema: true when creating a
SchemaRegistry to validate every schema registered through schemas: and
every schema passed to compile. The convenience compile, validate, and
valid? methods accept the same option and apply it to their internal registry.
Schemas without $schema use the Draft 7 meta-schema. An invalid schema raises
Schemurai::InvalidSchemaError, whose result contains the detailed validation
errors.
Schemurai.compile(schema, validate_schema: true)
registry = Schemurai::SchemaRegistry.new(
schemas: external_schemas,
validate_schema: true
)
validator = registry.compile(schema)
result = Schemurai.validate_schema(schema)
result.valid? # whether the schema itself is validSchemaRegistry#validate_schema and #valid_schema? perform explicit checks
regardless of the registry option and reuse meta-schema graph data within that
registry. The stateful validator used for a check is temporary; no global
validator is retained.
Opt-in validation limits (experimental)
max_errors: and timeout: are optional kwargs. Both default to nil
(unlimited) and apply independently to each validation call. max_errors must
be a nonnegative Integer; timeout must be finite nonnegative Integer or Float
seconds. No settings object is needed.
validator = Schemurai.compile(schema, max_errors: 100, timeout: 0.1)
begin
result = validator.validate(order)
rescue Schemurai::LimitExceeded => error
# Evaluation is incomplete; no partial result is returned.
warn "Validation stopped at #{error.limit}"
end
validator.validate(order, max_errors: 10) # retains the compiled timeout
validator.validate(order, timeout: nil) # retains the compiled error cap
validator.validate(order, max_errors: nil, timeout: nil) # disables both
validator.valid?(order) # applies the timeout, ignores max_errorsSchemaRegistry#compile, #validator_for, the convenience validate / valid?
methods and explicit meta-schema checks also accept these kwargs. Overrides
replace only the specified option. Settings belong to each compiled validator;
a shareable registry retains no mutable invocation counters.
The error cap checks before constructing the next emitted diagnostic. Exactly
max_errors errors may be returned; attempting another raises LimitExceeded
with limit == :max_errors, maximum and attempted counts. Suppressed
applicator trials do not consume the cap. Boolean checks ignore the error cap
and use the ordinary evaluator when no timeout is set.
The timeout uses a monotonic elapsed-time deadline, including diagnostic
rendering and native runtime helper calls and retries. It checks at entry,
before returning and every 256 checkpoints in evaluation, container scans and
structural traversal.
Ruby string/content operations also check before and after expensive calls.
Zero seconds stops even a trivial call. Expiry raises LimitExceeded with
limit == :timeout; maximum and attempted are configured and elapsed seconds.
Native returns an exhaustion status and releases Rust resources before Ruby
raises. A limit hit is terminal. Native validation does not fall back to VM
execution, with or without invocation limits.
This implementation never uses Ruby Timeout, timer threads, signals or
asynchronous exception injection. It is cooperative: one regexp search, scalar
operation, JSON decode, allocation or custom format callback may overrun until
the next checkpoint. GC and descheduling count toward elapsed time; the polling
interval bounds skipped checkpoints, not elapsed overrun. Compilation,
registration and automatic compile-time meta-schema validation are outside the
timeout. Explicit meta-schema checks can set their own timeout.
Use the error cap to bound diagnostic count and the timeout to bound prolonged traversal. They protect different resources: a deadline does not bound the number of diagnostics, and an error cap cannot stop a long successful check. Input byte size and nesting depth belong at the parsing boundary. An error cap does not bound the size of an individual message. Preempting individual scalar operations requires operation-specific limits or a supervised worker process. The backends retain their respective Ruby, VM and native execution paths and native memoization. Timed native bulk pattern/format/content and Fixnum contains scans use per-item evaluation so successful scans can poll.
Backend selection
Schemurai.backend, SchemaRegistry#backend, and Validator#backend expose
the selected backend. Pass backend: :ruby to force the Ruby oracle or
backend: :vm to ahead-of-time compile schemas for the validator virtual machine.
Pass backend: :native to use the experimental Rust VM extension. Releases
include precompiled gems on RubyGems.org for CRuby 4.0 on these platforms:
| OS | CPU | libc | Gem platform |
|---|---|---|---|
| Linux | x64 | glibc 2.36+ | x86_64-linux-gnu |
| Linux | arm64 | glibc 2.36+ | aarch64-linux-gnu |
| Linux | x64 | musl (Alpine 3.23+) | x86_64-linux-musl |
| Linux | arm64 | musl (Alpine 3.23+) | aarch64-linux-musl |
| macOS 14+ | x64 | — | x86_64-darwin |
| macOS 14+ | arm64 | — | arm64-darwin |
RubyGems/Bundler selects the matching platform gem during normal installation;
these gems need no C compiler, Rust, or Ruby development headers. Each platform
gem bundles its supported Ruby minor ABIs and restricts required_ruby_version
to that range. Ruby patch releases share the same binary. To build from source,
use gem install schemurai --platform ruby or Bundler's force_ruby_platform.
See native gem releases for the build matrix.
Building the native extension from source requires a C toolchain, Ruby
development headers, and Rust 1.93 or newer. If cargo is absent, installation of
the source gem skips the optional native
extension and Ruby/VM remain available; explicitly selecting :native then
raises LoadError. Native execution borrows Ruby values directly; its current coverage and
remaining VM delegation are documented below.
Schema graph construction remains shared Ruby infrastructure. Environment-based selection
is documented in docs/backend-selection.md.
JSON Schema conformance
| Capability | Draft 7 | Draft 2019-09 | Draft 2020-12 |
|---|---|---|---|
| Required JSON-Schema-Test-Suite cases | Supported | Supported | Supported |
| Dialect-specific references | $ref |
$ref, $recursiveRef
|
$ref, $dynamicRef
|
unevaluatedItems / unevaluatedProperties
|
Not applicable | Supported | Supported |
contentEncoding / contentMediaType assertions |
Opt-in1 | Opt-in1 | Opt-in1 |
The required-suite row covers every required case for the listed dialect in the official JSON Schema Test Suite. The applicable top-level optional cases are also tested, including arbitrary precision numbers, ECMA-262 regular expressions, anchors, cross-draft references, and dynamic references.
Format assertion support
Formats are annotations by default in each standard dialect. Pass format: true
to enable the supported assertions below.2
| Format | Assertion support |
|---|---|
date |
Supported |
time |
Supported |
date-time |
Supported |
duration |
Supported |
email |
Not supported |
idn-email |
Not supported |
hostname |
Not supported |
idn-hostname |
Not supported |
ipv4 |
Supported |
ipv6 |
Supported |
uri |
Not supported |
uri-reference |
Not supported |
iri |
Not supported |
iri-reference |
Not supported |
uuid |
Supported |
uri-template |
Not supported |
json-pointer |
Supported |
relative-json-pointer |
Supported |
regex |
Not supported |
Unsupported and unknown formats remain annotations when assertion is enabled by the caller.
Development
Run the test suite with:
bundle exec rspecVerify the reviewed official-suite classifications with:
ruby script/oracle-cases --summaryThe serialized oracle tools are script/oracle-runner and
script/oracle-compare.
Run the linter with:
bundle exec rubocopAI Disclosure
Large part of this work is generated by OpenAI Codex.
Footnotes
-
Pass
content: trueto assert Base64contentEncodingand JSONcontentMediaType. Other encodings and media types remain annotations. ↩ ↩2 ↩3 -
A custom Draft 2020-12 meta-schema that declares the Format-Assertion vocabulary can assert supported formats without the option and rejects unsupported formats during schema compilation. ↩