Schema-directed deserialization¶
The guarantee: if deserialization with schema= succeeds, the resulting
Document is guaranteed to conform to the given schema — every leaf matches
its declared scalar, and every field's shape and cardinality are correct. If
the input data doesn't fit the schema, deserialization fails with a
ParseError (listing every problem found, not just the first) instead of
returning a partial or non-conforming Document.
Every reader (read_json / read_yaml / read_toml / read_xml / read_oml,
and the matching Doc.from_*) produces a node from raw text.
Without a schema=, every leaf is exactly whatever the format's own
native parser produced — nothing is upgraded. With schema=, the reader
additionally converts each leaf to match the schema's declared
Scalar kind, wherever the conversion is value-exact.
This page covers that conversion: what changes, what doesn't, and why it's
safe to do without guessing.
Schema awareness is one-directional, read-only: a reader optionally
takes schema= to upgrade leaves on the way in, but no writer
(write_json/write_yaml/write_toml/write_xml/write_oml, or the
matching Doc.to_* methods) accepts a schema at all — a writer serializes
the Document exactly as it is, never consulting a schema for how to shape
the output.
flowchart LR
schema["Schema"] -. "schema=" .-> reader["reader (read_*)"]
text["format text"] --> reader --> doc1["Document"]
doc1 --> writer["writer (write_*)"] --> text2["format text"]
The core distinction, demonstrated¶
The same JSON text, read with and without a schema, can hand back a Document where the same field holds a different Python type. That's the whole point of the feature:
from omnist import parse_schema, read_json
text = '{"d": "2024-01-01", "n": 3}'
# No schema: leaves are exactly what JSON's own parser produces.
no_schema = read_json(text)
print(no_schema) # [('d', '2024-01-01'), ('n', 3)]
print(type(dict(no_schema)["d"])) # <class 'str'>
# With schema: leaves are additionally upgraded to match the declared Scalar.
s = parse_schema('record R { "d": date, "n": number }\nroot R')
with_schema = read_json(text, schema=s)
print(with_schema) # [('d', datetime.date(2024, 1, 1)), ('n', 3.0)]
print(type(dict(with_schema)["d"])) # <class 'datetime.date'>
Without schema=, the JSON string "2024-01-01" is a plain str — JSON has
no date type, so its parser can't produce anything else. With schema=,
the same string is upgraded to a real datetime.date because the schema
says the field d is a date and the string is a value-exact ISO-8601 date.
Likewise the JSON integer 3 becomes the Python float 3.0, because the
schema says n is a number.
What "no schema" already looks like, per format¶
The JSON "before" picture above — a leaf is just whatever the format's native parser hands back — is not the same starting point for every format. Some formats' own parsers already produce native Python temporal types for some scalars, with no schema involved at all:
| Format | A date leaf with no schema= |
|---|---|
| JSON | str (e.g. "2024-01-01") — JSON has no date type |
| YAML | datetime.date already — PyYAML's own loader recognizes unquoted ISO dates |
| TOML | datetime.date already — tomllib/TOML's grammar has a native date literal |
| XML | str (e.g. "2024-01-01") — XML has no date type |
| OML | str if written as a quoted string; OML has no separate date literal either, so a date leaf only becomes a real datetime.date once schema= upgrades it |
This means that for YAML and TOML, reading a date field without a schema
can already give you a datetime.date — passing schema= in that case is a
no-op for that field (the value's already value-exact for the declared
scalar). For JSON, XML, and OML, the upgrade from str to datetime.date
only happens once a schema is supplied. Verified directly:
from omnist import parse_schema, read_json, read_yaml, read_toml, read_xml
s = parse_schema('record D { "d": date }\nroot D')
type(dict(read_json('{"d": "2024-01-01"}'))["d"]) # str
type(dict(read_json('{"d": "2024-01-01"}', schema=s))["d"]) # datetime.date
type(dict(read_yaml('d: 2024-01-01'))["d"]) # datetime.date (already!)
type(dict(read_yaml('d: 2024-01-01', schema=s))["d"]) # datetime.date
type(dict(read_toml('d = 2024-01-01'))["d"]) # datetime.date (already!)
type(dict(read_toml('d = 2024-01-01', schema=s))["d"]) # datetime.date
type(dict(read_xml('<d>2024-01-01</d>'))["d"]) # str
type(dict(read_xml('<d>2024-01-01</d>', schema=s))["d"]) # datetime.date
Why the conversion is unambiguous by construction¶
A schema's field declares exactly one Scalar
(or one Ref) — never a union, never an enum of candidate types. So when
deserialization looks at a raw leaf value and a field's declared scalar,
there's never a choice between candidate representations to disambiguate
between — only one question: does this value exactly fit the one scalar
declared, or not. That's why the conversion can run automatically with no
configuration and no heuristics.
Passing schema= is the request for a guaranteed-conforming Document:
deserialization checks shape problems too — a missing or unexpected field,
the wrong cardinality, a record where a scalar is expected — not just
scalar conversions. There's no separate opt-in for this; if you don't want
shape checking (or scalar upgrading), don't pass schema= at all, and the
node comes back exactly as the format's own parser produced it. Once you
pass a schema, every problem it finds — scalar and shape, all of them, not
just the first — is collected into one raised error.
When a Document can't be made to conform: ParseError¶
If a leaf's raw value doesn't exactly fit the declared scalar, or the shape
doesn't match (an unknown field, a missing field, the wrong cardinality, a
record where a scalar was expected, or vice versa), deserialization raises
ParseError rather than guessing, silently leaving the value unconverted, or
returning a Document that doesn't actually match the schema:
from omnist import parse_schema, read_json, ParseError
s = parse_schema('record R { "n": integer }\nroot R')
read_json('{"n": "abc"}', schema=s)
# ParseError: $.n: 'abc' cannot be read as integer (not a value-exact conversion)
s2 = parse_schema('record R { "a": integer }\nroot R')
read_json('{"a": 1, "b": "extra"}', schema=s2)
# ParseError: $.b: unexpected field
1.5 into integer fails the same way (1.5 has a fractional part, so it's
not a value-exact int), while 4.0 into integer succeeds (4.0 is
value-exact as 4). If more than one problem exists, the ParseError
message lists every one of them, each on its own line with its path — the
same multi-error formatting Schema.validate uses.
XML's numeric and boolean strings are a read_xml-only exception¶
XML has no boolean/integer/number literals either — every leaf is text,
same as date (see XML). Unlike the date upgrade above,
this one is not part of materialize() itself: materialize() still
rejects a numeric-looking string for a boolean/integer/number field
in every format, on purpose — a string in JSON/YAML/TOML/OML is always a
deliberate choice, never an untyped placeholder the way it always is in
XML. read_xml(text, schema=s) does its own extra pass first, upgrading a
canonically-spelled numeric or boolean string — exactly what write_xml
itself would have produced — before handing the node to the shared
materialize():
from omnist import parse_schema, read_xml
s = parse_schema('record Inner { "ok": boolean, "n": integer, "total": number }\n'
'record R { "r": Inner }\nroot R')
read_xml('<r><ok>true</ok><n>3</n><total>29.97</total></r>', schema=s)
# [('r', [('ok', True), ('n', 3), ('total', 29.97)])]
The spelling has to be canonical, not merely parseable: "True" (capitalized),
a leading zero ("007"), or a leading + ("+3") are all things Python's
own bool()/int()/float() would accept but write_xml would never
produce, so none of them upgrade — the string reaches materialize()
unchanged and is rejected as usual.
Every problem is also available structurally on .errors, not just in the
message string — useful for a caller (an API server, say) that wants to turn
each one into its own field in a JSON error response rather than parsing text:
try:
read_json('{"a": 1, "b": "extra"}', schema=s2)
except ParseError as e:
for err in e.errors:
print(err.path, err.code, err.message)
# $.b unexpected-field unexpected field
Schema.validate still exists and is still useful on its own: it checks an
already-built Doc (one made with doc(), say) without re-deserializing
anything, and it's the only way to validate a Document you didn't just read
from text. What's changed is that schema=/materialize no longer needs a
second, separate validate() call afterward to catch shape problems — they
share the same checks, run together in one pass.
Conversion rules¶
The full, per-kind mapping of what validation accepts (checks a value
already in the document) versus what deserialization additionally converts
(and rejects) for each Scalar kind — along with the "bool never
satisfies integer/number," "number always deserializes to float,"
and "date/datetime stay mutually exclusive" notes that go with it —
lives in one place: model spec §10,
the formal definition this page's examples are derived from.
Inside any: no conversions, by design¶
At a field typed any (v0.5.0), schema-directed reading passes the
subtree through exactly as the reader produced it — the schema=None
behavior, scoped to that subtree. No upgrades (a date-shaped string stays
a string), and no downgrades either (a YAML-native date object stays a
date object):
import datetime
from omnist import parse_schema, read_json
s = parse_schema('record R { "when": datetime, "data": any }\nroot R')
node = read_json('{"when": "2026-07-01T09:30:00", '
'"data": {"since": "2024-01-01"}}', schema=s)
values = dict(node)
assert isinstance(values["when"], datetime.datetime) # sibling: upgraded
assert dict(values["data"])["since"] == "2024-01-01" # inside any: still a str
assert isinstance(dict(values["data"])["since"], str)
The conformance guarantee still holds trivially — any accepts every
legal Document value — but what you get inside is format-dependent raw
reading. Predictable, and worth knowing before you reach in.
materialize: upgrading an already-parsed node¶
schema= on a reader is sugar for parsing, then calling materialize
directly. Use materialize when you already have a node — from a reader
called without schema=, from doc(), or built by hand — and want the same
upgrade applied after the fact:
materialize(node, schema) -> node |
apply the schema-directed upgrade to an already-parsed node |
from omnist import materialize, parse_schema, read_json
s = parse_schema('record R { "d": date }\nroot R')
node = read_json('{"d": "2024-01-01"}') # no schema yet: 'd' is a str
materialize(node, s) # [('d', datetime.date(2024, 1, 1))]
See the API reference for the bare function signatures.