Skip to main content

Zodish

tests PyPI Python License: MIT

A validation library (written in Rust via PyO3) for CPython 3.14+ (tested on 3.14 and 3.15), with a typed Python API inspired by Zod. Synchronous parsing is the default; composable async refinements and transforms are supported through explicit async entry points.

Handles: strings, numbers, integers, booleans, none, unknown, literals, enums, objects, arrays, sets, maps, tuples, records, unions, discriminated unions, intersections, optional/nullable/default wrappers, coercion, stringbool, bidirectional codecs, refinements, transforms, pipes, recursive schemas, metadata, and JSON Schema export — with immutable schemas, ordered operations, aggregated issues, and native-only evaluation. Zodish is an independent implementation of a subset of Zod's API.

Documentation

Detailed documentation follows the Diátaxis framework and lives in docs/:

  • Tutorials — learn by building a schema step by step.
  • How-to guides — solve a specific validation task.
  • Reference — APIs, error codes, and JSON Schema support.
  • Explanation — design decisions and semantics.

Installation

pip install zodish

Build from source (requires Rust 1.85+ and CPython 3.14+):

python3.14 -m venv .venv
source .venv/bin/activate
python -m pip install .
python examples/quickstart.py

On Windows, activate the environment with .venv\Scripts\Activate.ps1 in PowerShell. The remaining commands use the environment's python.

There are no third-party Python runtime dependencies. Source builds use maturin and PyO3 and download build dependencies unless they are already cached. Installing a compatible prebuilt wheel does not require Rust. Official wheels use the GIL-enabled CPython 3.14+ stable ABI and must match the operating system and architecture; an Apple Silicon macOS wheel cannot be used on Linux or Windows. Free-threaded wheels are not part of the published compatibility matrix.

Quick Start

from zodish import z

user_schema = z.object({
    "name": z.string().trim().min(1),
    "email": z.string().trim().lower().email(),
    "age": z.integer().min(18),
    "role": z.enum(["user", "admin"]).default("user"),
    "nickname": z.string().optional(),
}).strict()

user = user_schema.parse({
    "name": " Alex ", "email": "ALEX@example.com", "age": 30,
})
# {"name": "Alex", "email": "alex@example.com", "age": 30, "role": "user"}

result = user_schema.safe_parse({"name": "", "email": "bad", "age": 12})
if result.success:
    print(result.data)
else:
    print(result.error.to_dict())
    # {"issues": [{"code": "too_small", "minimum": 1, ...}, ...]}

parse returns validated output or raises ValidationError. safe_parse returns a Success[T] | Failure discriminated union. Both validate nested objects and collect sibling errors, including array indices in error paths. A union failure includes each branch's issues in branches. parse_json, safe_parse_json, and validate_json apply the same schema directly to JSON str, bytes, or bytearray input. parse_async, safe_parse_async, and validate_async await async refinements and transforms anywhere in a schema; parse_json_async and safe_parse_json_async do the same for JSON text. See Async support.

Errors

Issues have a code, a message, a tuple path, and structured params. Paths contain object keys and collection indices. Codes include too_small, too_big, not_multiple_of, invalid_type, invalid_value, invalid_format, invalid_key, unrecognized_key, invalid_length, invalid_union, required, and custom. Use to_dict() for a JSON-serializable representation; params are merged into each serialized issue.

import json
from zodish import ValidationError, z

schema = z.object({"users": z.object({"age": z.integer().min(18)}).array()})
result = schema.safe_parse({"users": [{"age": 12}]})
assert not result.success
issue = result.error.issues[0]
assert issue.path == ("users", 0, "age")
assert issue.code == "too_small"
assert issue.params == {"minimum": 18, "inclusive": True, "origin": "number"}
print(json.dumps(result.error.to_dict(), indent=2))

try:
    schema.parse({"users": [{"age": "twelve"}]})
except ValidationError as error:
    assert error.issues[0].code == "invalid_type"
    print(error.flatten())   # form_errors and field_errors
    print(error.treeify())   # nested errors, properties, and items
    print(error.prettify())  # human-readable lines

API Overview

Schema Constructors / methods
Strings z.string(), .min(n), .max(n), .length(n), .regex(pattern), .email(), .url(), .uuid(), .ulid(), .cuid(), .cuid2(), .nanoid(), .jwt(), .e164(), .base64(), .base64url(), .date(), .time(), .datetime(), .ipv4(), .ipv6(), .hostname(), .starts_with(s), .ends_with(s), .includes(s), .trim(), .lower(), .upper()
Numbers z.number(), z.integer(), .min(n), .max(n), .gt(n), .gte(n), .lt(n), .lte(n), .multiple_of(n), .positive(), .nonnegative(), .int()
Other values z.boolean(), z.none(), z.unknown(), z.date(), z.datetime(), z.time(), z.timedelta(), z.decimal(), z.bytes(), z.path(), z.json(), z.native_enum(EnumClass), z.instance_of(cls)
Choices z.literal(value), z.enum(values), z.union(*schemas), z.discriminated_union(key, options), schema.or_(other)
Objects z.object(shape), .strict(), .strip(), .passthrough(), .catchall(schema), .extend(shape), .pick(*keys), .omit(*keys), .partial(*keys), .required(), .keyof()
Collections z.array(item) / item.array(), array .min(n), .max(n), .length(n), .nonempty(), z.set(item), z.map(key_schema, value_schema), z.tuple(*items) / .rest(schema), z.record(value_schema) / z.record(key_schema, value_schema)
Every schema .parse(value), .safe_parse(value), .validate(value), .parse_json(data), .safe_parse_json(data), .validate_json(data), .parse_async(value), .safe_parse_async(value), .validate_async(value), .parse_json_async(data), .safe_parse_json_async(data), .encode(value), .safe_encode(value), .optional(), .nonoptional(), .nullable(), .nullish(), .default(value), .default_factory(fn), .default_factory_async(fn), .catch(value_or_factory), .catch_async(factory), .unwrap(), .refine(predicate, message, *, path=()), .refine_async(predicate, message, *, path=()), .super_refine(fn), .super_refine_async(fn), .transform(fn), .transform_async(fn), .overwrite(fn), .overwrite_async(fn), .pipe(schema), .meta(metadata), .describe(text), .metadata, .to_json_schema(...)
Codecs z.codec(input_schema, output_schema, *, decode=fn, encode=fn), z.codec_async(...), .decode(value), .safe_decode(value), .decode_async(value), .safe_decode_async(value), .encode(value), .safe_encode(value), .encode_async(value), .safe_encode_async(value), .input_schema, .output_schema, .optional(), .nullable(), .nonoptional(), .nullish(), .record(), z.codecs.array(codec), z.codecs.union(*schemas), z.codecs.tuple(*schemas)
Built-in codecs z.codecs.iso_date(), iso_time(), iso_datetime(), decimal(), json_text(), path(), bytes_base64(urlsafe=False), dataclass(Cls, fields=None)
Coercion z.coerce.string(), z.coerce.number(), z.coerce.integer(), z.coerce.boolean(), z.string().stringbool(...)
JSON Schema z.to_json_schema(schema, *, target=..., io=..., unrepresentable=...)
Composition z.intersection(left, right) / schema.and_(other), z.preprocess(fn, schema), z.preprocess_async(fn, schema), z.lazy(factory)

Built-in constraints accept a keyword-only message=. .refine() accepts a relative path=, and .super_refine() may return one Issue, a list or tuple of Issue objects, or None; returned issue paths are relative to the current location. z.enum() reports one invalid_value issue rather than a union branch.

Schemas are immutable: fluent methods return a new schema. Operations execute in declaration order: .trim().min(1) validates the trimmed string. Structural object methods operate on the shape. Apply .refine() or .super_refine() after structural edits; they return a general Schema[T], as do transforms, pipes, and optional/default wrappers. Type-specific methods should therefore precede these wrappers.

Missing, None, and Defaults

from zodish import MISSING, z

z.string().optional().parse() is MISSING  # True
z.string().nullable().parse(None)        # None
z.string().default("guest").parse()      # "guest"
z.string().default("guest").safe_parse(None).success  # False
  • Fields are required by default, including z.unknown().
  • optional() permits missing input; it does not permit None.
  • nullable() permits None; it does not permit missing input.
  • nullish() permits both.
  • Missing optional object fields are omitted from output.
  • Defaults apply only to missing input. A default is copied and then passed through the schema, so constraints and transforms also apply to defaults. This differs from Zod 4's short-circuit default behavior.
  • default_factory runs only for missing input. A default inside an outer optional() is skipped for missing input because the outer wrapper short-circuits.

Wrapper order affects missing values:

from zodish import MISSING, z

assert z.string().default("guest").optional().parse() is MISSING
assert z.string().optional().default("guest").parse() == "guest"
assert z.object({"tags": z.string().array().default_factory(list)}).parse({}) == {
    "tags": [],
}

Fallbacks and Overwrites

from zodish import z

score = z.integer().catch(0)          # validation failure or missing -> 0
score.parse("bad")                    # 0
score.parse(10)                       # 10

z.string().optional().nonoptional().safe_parse()  # Failure: required
z.string().overwrite(str.upper).parse("ab")       # "AB"
z.integer().validate(1)                           # True
z.string().optional().unwrap()                    # the original string schema
  • .catch(value) returns a deep-copied fallback for validation failures and missing input. A zero-argument callable is invoked instead of copied. Exceptions raised by callbacks still propagate.
  • .nonoptional() rejects missing input, overriding an inner optional/default.
  • .overwrite(fn) transforms a parsed value without changing its static type.
  • .validate(value) returns a boolean; callback exceptions still propagate.
  • .unwrap() returns the tracked inner schema for wrappers and container elements/values, and raises TypeError when no inner schema is tracked.

Objects and Collections

from zodish import z

user = z.object({"name": z.string(), "age": z.integer()}).strict()
user.parse({"name": "Alex", "age": 30})
# {"name": "Alex", "age": 30}
user.safe_parse({"name": "Alex", "age": 30, "admin": True}).success  # False

Objects accept Python dictionaries with string keys. Extra keys are stripped by default; .strict() rejects them and .passthrough() preserves them. Arrays accept lists; set schemas accept Python sets and return new sets; map schemas use dictionaries with a validated key schema and value schema. Tuple schemas accept lists or tuples and return tuples. Records accept dictionaries with string keys and validate every value.

z.array(z.integer()).nonempty()
z.set(z.integer())
z.map(z.string(), z.integer())  # dictionary with validated keys and values
z.tuple(z.string(), z.integer()).rest(z.boolean())
z.record(z.string())
z.record(z.integer(), z.string())  # keyed record

.required() makes every object field reject missing input, including fields with .optional() or defaults. .partial(*keys) makes all fields optional, or only the selected keys. .keyof() returns an enum schema of the shape keys. .length(n) on strings and arrays requires exactly n characters or items.

.catchall(schema) validates unknown object keys and preserves them in the output; .strict(), .strip(), and .passthrough() clear it. Records accept an optional key schema: z.record(z.integer(), z.string()) validates keys before values and uses parsed keys in the output. Tuples support a trailing rest schema: z.tuple(z.string()).rest(z.integer()).

No automatic coercion occurs. Booleans are not numbers. z.number() accepts exact Python int values and finite exact float values; z.integer() accepts exact int values. Numeric subclasses are rejected. The .int() constraint on a number also permits integral floats, retaining their type. Literals compare both type and value, so True, 1, and 1.0 are distinct. Integer diagnostics use bit counts when Python's decimal conversion limit prevents formatting. String operations use the built-in str methods, including for string subclasses.

Integer comparisons retain arbitrary precision, including for bounds and literals:

from zodish import z

huge = 10**5000
assert z.integer().min(huge).parse(huge + 1) == huge + 1
assert z.literal(huge).parse(huge) == huge
assert not z.literal(huge).safe_parse(huge + 1).success

Parsed object/list/record containers are newly constructed. Preserved extra fields and constant defaults are deep-copied. unknown(), instance_of(), and custom callbacks may return existing references; there is no universal deep-copy guarantee.

Choices, Unions, and Intersections

Literals and enums

z.literal("admin")
z.enum(["user", "admin"])
z.union(z.string(), z.integer())
z.string().or_(z.integer())

Unions validate each branch in order and return the first success; a failure includes every branch's issues in branches. z.enum() reports a single invalid_value issue.

Discriminated unions

z.discriminated_union(key, options) dispatches on a dictionary key. Mapping keys are the expected discriminator values; the selected branch validates the whole dictionary.

event = z.discriminated_union("kind", {
    "created": z.object({"kind": z.literal("created"), "id": z.integer()}),
    "deleted": z.object({
        "kind": z.literal("deleted"), "id": z.integer(), "reason": z.string(),
    }),
})

event.parse({"kind": "created", "id": 1})

A missing discriminator reports required at (key,); an unknown value reports one invalid_value issue with values params. The selected branch controls the output, so include the discriminator in each branch shape to preserve it.

Intersections

z.intersection(left, right) or left.and_(right) validates the same input against both schemas and merges dictionary outputs:

both = z.intersection(
    z.object({"name": z.string()}),
    z.object({"age": z.integer()}),
)

both.parse({"name": "Alex", "age": 30})
# {"name": "Alex", "age": 30}

Both schemas must produce dictionaries; otherwise validation reports invalid_intersection_types. Nested dictionaries merge recursively; conflicting non-dictionary values take the right side. Missing input is rejected, so wrap the intersection in .optional() when absence is allowed. Strict object components still reject each other’s keys, just as they do at runtime.

Comparisons and String Predicates

z.integer().gt(0).lt(100)       # exclusive bounds
z.integer().gte(0).lte(100)     # inclusive aliases for min/max
z.number().multiple_of(0.25)
z.string().starts_with("v").ends_with(".json").includes("config")

Exclusive bound failures use too_small/too_big with inclusive=False; multiple-of failures use not_multiple_of with the divisor in params; and string predicate failures use invalid_format. Integer multiple checks are exact. Float checks compare the quotient to the nearest integer with a small relative tolerance, so 0.3 counts as a multiple of 0.1 despite binary floating-point representation. Divisors must be positive finite numbers.

Formats and Python Scalars

Common string formats are syntax checks:

z.string().url()
z.string().uuid()
z.string().ulid()
z.string().cuid()
z.string().cuid2()
z.string().nanoid()
z.string().jwt()
z.string().e164()
z.string().base64()
z.string().base64url()
z.string().date()
z.string().time()
z.string().datetime()
z.string().ipv4()
z.string().ipv6()
z.string().hostname()

Python-native value schemas validate object types rather than strings:

z.date()        # datetime.date, excluding datetime.datetime
z.datetime()    # datetime.datetime
z.time()        # datetime.time
z.timedelta()   # datetime.timedelta
z.decimal()     # decimal.Decimal
z.bytes()       # exact bytes
z.path()        # pathlib.PurePath and subclasses
z.json()        # recursively JSON-compatible Python values
z.native_enum(Color)  # returns Color members

z.json() accepts None, booleans, integers, finite floats, strings, lists, tuples, and dictionaries with string keys. Cycles raise RecursionError. z.native_enum() returns enum members, not raw values. JSON Schema exports z.json() as {} and native enums as their JSON-compatible member values; the other Python object schemas are not representable.

Coercion and stringbool

z.coerce.string()   # str, int, or finite float -> str
z.coerce.number()   # int, finite float, or numeric string -> int | float
z.coerce.integer()  # int, integral float, or integer string -> int
z.coerce.boolean()  # bool, 0/1, or true/false-like string -> bool

z.string().stringbool()
z.string().trim().stringbool()

Coercion is explicit and Python-specific:

  • Strings accept str, exact int, and finite exact float; booleans and containers are rejected.
  • Numbers accept exact int/finite float and strings parsed with int first, then float; booleans and non-finite results are rejected.
  • Integers accept int, integral floats, and integer strings; "3.5" is rejected.
  • Booleans accept bool, 0/1, and the case-insensitive strings true, 1, yes, on, false, 0, no, and off.
  • Failed conversions report invalid_type instead of raising programming errors.
  • Static annotations still describe the validated type; coercion widens runtime inputs beyond what the type checker sees.

z.string().stringbool() normalizes case by default and returns a Python bool. truthy, falsy, case_sensitive, and message are configurable; use .trim() first when surrounding whitespace is acceptable. JSON Schema exports coercion as its output type. stringbool exports as boolean in output mode and as a string enum in input mode; case-insensitive variants are not enumerated.

Codecs

A codec validates and converts between an encoded and a decoded schema in both directions:

from datetime import datetime
from zodish import z

iso_datetime = z.codec(
    z.string().datetime(),
    z.datetime(),
    decode=lambda text: datetime.fromisoformat(text),
    encode=lambda value: value.isoformat(),
)

iso_datetime.parse("2026-10-20T12:30:00")   # datetime(...)
iso_datetime.decode("2026-10-20T12:30:00")  # same as parse
iso_datetime.encode(datetime(2026, 10, 20, 12, 30))
# "2026-10-20T12:30:00"

Use z.codec_async() when either conversion awaits I/O, then call .decode_async()/.encode_async() (or their safe variants):

lookup = z.codec_async(
    z.string(),
    z.integer(),
    decode=record_id_for_text,
    encode=text_for_record_id,
)
value = await lookup.decode_async("42")
encoded = await lookup.encode_async(42)

Async codecs preserve .input_schema, .output_schema, optional/nullable wrappers, records, arrays, unions, and tuples. Dataclass codecs support async field codecs through .decode_async()/.encode_async().

Built-in helpers cover common string boundaries:

z.codecs.iso_date()       # "2026-10-20" <-> date
z.codecs.iso_time()       # "12:30:00" <-> time
z.codecs.iso_datetime()   # ISO datetime <-> datetime
z.codecs.decimal()        # decimal string <-> Decimal
z.codecs.json_text()      # JSON text <-> JSON-compatible values
z.codecs.path()           # path string <-> pathlib.Path
z.codecs.bytes_base64()   # padded base64 <-> bytes
z.codecs.bytes_base64(urlsafe=True)

Each returns a CodecSchema. Invalid JSON reports invalid_format with a position; base64 and decimal syntax failures report the corresponding format.

Structured domain objects use an explicit field map:

z.codecs.dataclass(User, fields={
    "name": z.string(),
    "created": z.codecs.iso_datetime(),
})                        # encoded dict <-> dataclass instance

z.codecs.dataclass() infers field schemas from type annotations; pass fields= to override selected fields. Supported annotations include primitives, bytes, date/time/datetime/timedelta, Decimal, Path, Enum, Literal, list/Sequence, dict/Mapping with string keys, fixed tuples, unions and Optional, Any/object, and Annotated. Inference describes Python values; override date/time, Decimal, Path, or enum fields with codecs when the encoded form is a string or raw value. Plain schemas act as validators; codec overrides supply encoded and decoded forms. Nested dataclasses and collections or unions containing them are inferred recursively; recursive dataclass cycles are rejected. InitVar is unsupported. init=False fields are ignored. Fields with dataclass defaults may be omitted from input, and the dataclass default wins over a field-schema default. A field schema that permits missing input without a dataclass default is rejected. Input-mode JSON Schema describes the encoded object; output mode describes the decoded object, so Python-only field values remain unrepresentable.

Codec combinators preserve both directions:

z.codecs.array(codec)      # list[encoded] <-> list[decoded]
codec.record()             # dict[str, encoded] <-> dict[str, decoded]
codec.optional()           # missing or encoded <-> missing or decoded
codec.nullable()           # None or encoded <-> None or decoded
codec.nonoptional()
codec.nullish()
z.codecs.union(codec_or_schema, ...)
z.codecs.tuple(codec_or_schema, ...)

z.codecs.union() and z.codecs.tuple() accept codecs or plain validator schemas; plain schemas act as identity branches. Item errors carry list indices, dictionary keys, or tuple positions, and aggregate across siblings.

  • Decoding validates encoded input, runs decode, then validates the result through the output schema. Encoding validates decoded input, runs encode, then validates the result through the input schema.
  • .parse() and .decode() are equivalent. .safe_decode() and .safe_encode() return Success/Failure for expected validation failures; programming errors from callbacks propagate.
  • Codecs compose in objects, arrays, tuples, records, unions, discriminated unions, intersections, and optional/nullable/nonoptional wrappers. .encode() walks a reversible schema tree, so container encoding is available when every component is reversible.
  • Reversible components are primitives, validators/constraints, codecs, optional/nullable/nonoptional wrappers, and structural containers. One-way operations — transforms, overwrites, refinements, coercions and stringbool, defaults and default_factory, catches, pipes, preprocessors, and lazy schemas — have no reverse path; .encode() raises TypeError when they are present.
  • Use validator schemas for both sides of a codec. One-way transforms inside the input or output schema are not supported for encoding.
  • z.codec callbacks are synchronous; use z.codec_async and .decode_async()/.encode_async() for awaitable conversions.
  • Dataclass codecs support async field codecs through .decode_async()/.encode_async(); use the synchronous methods only when every reached field is synchronous.
  • JSON Schema export uses the input schema for io="input" and the output schema for io="output". A decoded side containing Python-only values such as datetime is not representable.

Refinements and Transforms

from dataclasses import dataclass
from zodish import z

@dataclass(frozen=True)
class User:
    name: str
    age: int

user_schema = z.object({
    "name": z.string().min(1),
    "age": z.integer().nonnegative(),
}).transform(lambda data: User(**data))

user: User = user_schema.parse({"name": "Alex", "age": 30})

positive_length = z.string().transform(len).pipe(z.integer().positive())
ordered = z.object({"start": z.integer(), "end": z.integer()}).refine(
    lambda data: data["start"] <= data["end"], "start must not exceed end",
)

Refinement paths are relative to the current location:

from zodish import Issue, z

credentials = z.object({
    "password": z.string(),
    "confirm": z.string(),
}).super_refine(lambda value: Issue(
    "custom", "Passwords must match", ("confirm",),
) if value["password"] != value["confirm"] else None)

Schema[Out, In] tracks parsed output and accepted input types for primitives, homogeneous collections (including sets and maps), transforms, preprocessors, and wrappers. In defaults to Out, so Schema[T] remains valid when input and output types match. default() and default_factory() accept input-typed values; after a transform, pass the pre-transform type or apply the default before the transform. Python type checkers cannot generally derive a new TypedDict from a dictionary of schema instances as TypeScript does with z.infer. Objects return dict[str, Any]; z.tuple() infers fixed-length tuple types for up to four items, while larger tuples and tuples with a rest schema return tuple[Any, ...]. Use z.codecs.dataclass(Cls, fields=...) or a transform to produce a statically typed dataclass result. Unions infer up to four alternatives; larger unions fall back to Any. There is no type-checker plugin or generated model API.

safe_parse catches only ValidationError. Exceptions raised by callback code such as int("bad") propagate. Use .refine() to reject expected invalid values before a transform; use z.preprocess for intentional input conversion. Callbacks can raise ValidationError to contribute issues to the surrounding validation. Supplied issue paths are preserved as written. A callback raising ValidationError([]) produces a custom issue at the current path with the message Invalid value, so the rejection still aggregates with sibling failures. Sync callbacks are used by default. Asynchronous checks are available through refine_async/super_refine_async/transform_async/overwrite_async/default_factory_async/catch_async/z.preprocess_async and parse_async/safe_parse_async/validate_async/parse_json_async/safe_parse_json_async, including inside objects, arrays, records, tuples, unions, intersections, pipes, and wrappers; async callbacks passed to synchronous builders are rejected. z.codec_async supports awaitable codec callbacks and encode_async/safe_encode_async support async reverse graphs, including async dataclass codecs. Lazy factories remain synchronous; a factory may return a schema containing async operations. See Async support for details and limitations.

Recursion

from typing import Any
from zodish import Schema, z

node: Schema[dict[str, Any]] = z.lazy(lambda: z.object({
    "name": z.string(),
    "children": node.array().default([]),
}))

node.parse({"name": "root", "children": [{"name": "leaf"}]})

Inputs must be acyclic. The native evaluator raises RecursionError at 256 nested schema-node evaluations, including wrappers, to bound Rust stack use. A lazy node resolves its factory once per parse and reuses that schema for the rest of the parse; a later parse invokes the factory again. Python callbacks may additionally hit Python's recursion limit. Containers are shallow-snapshotted before traversal so callbacks can mutate the original list or dictionary without invalidating the iteration. Email validation checks basic syntax only; regexes use Python's re.search behavior. This core implements async composition, callback families, JSON parsing, async codecs, async dataclass codecs, and async encoding, but it does not implement async lazy factories, JSON Schema import, or all Zod formats. See Async support.

Metadata

Attach documentation metadata to any schema:

from zodish import z

user = z.object({
    "name": z.string().min(1).meta({"title": "Name", "examples": ["Alex"]}),
}).meta({"title": "User", "description": "A registered user"})

user.metadata["title"]  # "User"
user.describe("Updated description").metadata["description"]

metadata is a read-only mapping. .meta() merges keys and overrides existing ones; .describe() replaces description. Metadata follows value-preserving derivations such as constraints, refinements, transforms, optional/default wrappers, and object structural methods. Collection and composition constructors such as .array(), .or_(), z.union(), z.record(), and z.intersection()/.and_() start with empty metadata. Metadata does not affect validation, equality, or hashing.

Container and composition schemas retain their child schemas: .item on arrays, .value_schema on records, .items on tuples, .options on unions, and .options (a mapping) on discriminated unions. This keeps nested metadata available to tooling such as a future JSON Schema exporter.

JSON Schema Export

Export the supported subset with to_json_schema() or schema.to_json_schema():

from zodish import to_json_schema, z

schema = z.object({
    "name": z.string().min(1).meta({"title": "Name"}),
    "age": z.integer().min(18),
})

to_json_schema(schema)
# {"type": "object", "properties": {...}, "required": [...],
#  "additionalProperties": False}

to_json_schema(schema, target="openapi-3.0", io="input")
  • target is "draft-2020-12" (default) or "openapi-3.0".
  • io is "output" (default) or "input"; it affects required fields, defaults, and the object extra-key policy.
  • Metadata title, description, examples, and other keys are merged into the output and override generated keys. For OpenAPI 3.0, examples is emitted as the singular example. Metadata values are copied as-is; use JSON-compatible values when serializing the result.
  • Supported: strings with length/regex/format and starts/ends/includes constraints, numbers/integers with inclusive or exclusive bounds and multiple-of checks, booleans, null, unknown, JSON values, native enums, literals, enums, arrays, tuples with optional rest (draft only), objects with catchall, records, unions, discriminated unions, intersections with dictionary outputs, optional/nullable/default wrappers, coercion wrappers, stringbool, and codecs. Static defaults must be JSON-compatible.
  • Multiple string predicate patterns and multiple multiple_of constraints are emitted through allOf; multiple raw regex constraints on one string remain unrepresentable.
  • Not representable: catch, refine/super_refine, transform, overwrite, preprocess, pipe, lazy, default_factory, instance_of, trim/lower/upper, and regexes with non-default flags. Export raises UnrepresentableError by default; pass unrepresentable="any" to emit {} for those nodes instead.
  • Tuples, keyed records, and null-only schemas are not representable in OpenAPI 3.0. Set schemas are unrepresentable in both targets; use an array when JSON Schema output is required. Map schemas export as keyed records. Use .nullable() for nullable fields; standalone null literals and enum values containing None are rejected.
  • Keyed records in draft JSON Schema require keys that produce strings. Integer, transformed, or otherwise non-string key schemas are rejected; one-argument records keep the default string-key behavior.
  • String formats are JSON Schema annotations; validators enforce them only when format assertion is enabled. Python regex patterns are emitted as pattern, so Python-specific syntax may not be portable.
  • JSON Schema multipleOf uses exact numeric semantics. Runtime integer checks are exact, but float checks apply a small quotient tolerance, so results can differ for decimal values such as 0.3 with divisor 0.1.
  • Python object schemas such as z.date(), z.datetime(), z.time(), z.timedelta(), z.decimal(), z.bytes(), z.path(), and z.instance_of() are not representable in JSON Schema. Native enum values must themselves be JSON-compatible.
  • Output mode uses additionalProperties: false for stripping objects; input mode omits it, matching which input keys are accepted.
  • optional is represented through object required lists; it has no standalone JSON Schema equivalent. unknown becomes {} and can describe only JSON values.
  • JSON Schema cannot preserve Python's exact numeric-type distinctions, including True/1/1.0 literals and int versus integral float. Runtime validation still enforces them.
  • Discriminated unions export as oneOf; include each discriminator literal in its branch for precise matching.
  • Intersections export as allOf. Strip-object components omit additionalProperties inside the intersection so combined inputs validate. Intersections whose statically known outputs are not dictionaries are unrepresentable.
  • Coercion exports as its output type; broader accepted runtime inputs are not represented. stringbool input mode enumerates the configured strings but not all case variants.
  • Codecs export the input schema for io="input" and the output schema for io="output". The selected side may itself be unrepresentable, for example a decoded datetime.

Architecture

The core lives in rust/lib.rs. Python schema builders construct native nodes; parse() enters the Rust evaluator once. Rust handles schema traversal, primitive validation, collection construction, bounds, optional/default handling, unions, and issue aggregation. There is no Python validation fallback. User callbacks, Python regular expressions, string case/whitespace operations, and deepcopy use Python operations to preserve their existing behavior. The public result/error classes and static typing remain Python code.

Performance

Built in Rust for speed. Typical benchmarks (Apple M4 Max, Python 3.14.3, parse unless noted):

Scenario Operations/sec Time per operation
z.string().min(3).max(32) 7,475,470 0.13 µs
Object parse 2,642,532 0.38 µs
Object failure (safe_parse) 977,485 1.02 µs
Object failure, issues inspected 437,031 2.29 µs
1,000-element integer array 173,938 5.75 µs
Union, first branch 4,802,304 0.21 µs
Union, last branch 1,457,000 0.69 µs
Recursive tree (depth 4) 110,799 9.03 µs
Refinement callback 7,066,718 0.14 µs

Numbers are medians over seven repetitions from benchmarks/bench_parse.py. The array row uses the native fast path for primitive item validators and exact-list iteration, the recursive row benefits from per-parse lazy resolution, object parsing skips the unknown-key scan and input copy when every input key belongs to the shape, and failures defer Python Issue materialization and message formatting until they are read. Results depend on the host, Python version, and native build profile; the harness has no pass/fail thresholds. A pinned cross-library comparison with Pydantic, msgspec, and Marshmallow is recorded under benchmarks/results/, along with a release-wheel async benchmark summary.

Development

With the virtual environment activated, install the package in editable mode with the development tools, then run the checks from the repository root:

python -m pip install --group dev -e .
python -m unittest discover -s tests -v
python examples/quickstart.py
python benchmarks/bench_parse.py --smoke
python benchmarks/bench_async.py --smoke
ruff check .
mypy
cargo fmt --check
cargo clippy --locked -- -D warnings

Editable installation picks up Python edits. After changing any source under rust/, rebuild the native extension with python -m pip install -e . before running tests; an editable install does not automatically rebuild Rust code.

If just is installed, run just to list the recipes. Use PYTHON=python just install to install dependencies, just check for static checks, PYTHON=python just ci to run static checks, tests, and examples, or just bench for synchronous benchmarks or just bench-async for the async harness. PYTHON selects the interpreter for recipes that run Python; its default is python3.14.

Ruff and strict mypy settings live in pyproject.toml; the dev dependency group installs both tools. Python 3.14+ syntax is intentional. py.typed is included for type checkers. Cargo.lock pins the Rust dependencies. The CI workflow runs tests, the quickstart, and synchronous/async benchmark smoke runs on Ubuntu with Python 3.14 and 3.15. Its packaging job builds the wheel and sdist on Ubuntu, macOS, and Windows with Python 3.14, verifies both artifact contents, then installs each artifact and runs the test suite, quickstart, and native-extension check against it; it also re-runs the abi3 wheel smoke test under Python 3.15 on Linux. Official artifacts target GIL-enabled CPython 3.14+; free-threaded wheels are not part of the release matrix. The typing examples are checked by mypy.

Benchmarks

The benchmark harness measures successful and failing object parses, a failure whose issues are inspected, a 1,000-element array, union branch position, a recursive tree, and a Python refinement callback:

python benchmarks/bench_parse.py
python benchmarks/bench_parse.py --json
python benchmarks/bench_parse.py --filter union

It reports median throughput over repeated runs and has no pass/fail thresholds: results depend on the host, Python version, and native build profile. The failing scenarios use safe_parse, and one of them reads .issues; the other scenarios use parse. --smoke uses minimal counts for CI.

The async harness measures synchronous graphs through parse_async, immediately completing callbacks, asyncio.sleep(0) suspension, sequential callbacks, nested failures, and concurrent parses. Event loops are created outside timed iterations, and --memory records retained and peak tracemalloc values:

python benchmarks/bench_async.py
python benchmarks/bench_async.py --json
python benchmarks/bench_async.py --filter concurrent --memory

For comparisons with Pydantic, msgspec, and Marshmallow, use the optional pyperf harness:

python -m pip install --group benchmark
python benchmarks/bench_compare.py --check-only
python benchmarks/bench_compare.py --build-profile release -o comparison.json
python benchmarks/summarize.py comparison.json --output comparison.md

Use --build-profile release only with a verified release build. See the benchmark guide for installation, scenario coverage, semantic differences, and result interpretation.

Build distributions

Install maturin into the environment to make its CLI available, then build a release wheel and source distribution:

python -m pip install 'maturin>=1.15,<2'
maturin build --release --locked --interpreter python --out dist
maturin sdist --out dist
python scripts/verify_dist.py dist

Artifacts are written to dist/. just dist runs the two maturin build commands when maturin is already installed, and just dist-check also verifies the wheel layout, stable ABI tag, sdist contents, and version agreement. In a clean environment, install an artifact and run python examples/verify_native.py to confirm that the compiled extension, rather than a source fallback, is loaded.

Stability & Versioning

Zodish follows Semantic Versioning. Until 1.0, minor releases may include breaking changes; they are recorded in CHANGELOG.md.

Covered by SemVer guarantees:

  • The z schema builders, schema methods, and parse/safe_parse/validate/parse_json/safe_parse_json/validate_json/parse_async/safe_parse_async/validate_async/parse_json_async/safe_parse_json_async/encode/safe_encode/encode_async/safe_encode_async behavior.
  • The public result and error classes: ValidationError, Issue, Success, Failure, and MISSING.
  • Schema[Out, In] typing and the py.typed marker.
  • JSON Schema export behavior for the supported subset.

Not covered:

  • Rust internals (rust/), benchmarks, and CI configuration.
  • Error message wording and performance numbers, which may change within a minor release.

Author

Aleksandr Pavlov ckidoz@gmail.com

License

MIT — see LICENSE for details.

Metadata

Release files for zodish 0.6.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for zodish 0.6.0
File Size Uploaded Provenance
zodish-0.6.0.tar.gz 269.8 kB With provenance Details

Built distributions (wheels)

Table of built distributions (wheels) for zodish 0.6.0
File
zodish-0.6.0-cp314-abi3-win_amd64.whl CPython 3.14 abi3 Windows x86-64 Details
zodish-0.6.0-cp314-abi3-musllinux_1_2_x86_64.whl CPython 3.14 abi3 Linux musl 1.2+ x86-64 Details
zodish-0.6.0-cp314-abi3-musllinux_1_2_aarch64.whl CPython 3.14 abi3 Linux musl 1.2+ ARM64 Details
zodish-0.6.0-cp314-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl CPython 3.14 abi3 Linux glibc 2.17+ x86-64 Details
zodish-0.6.0-cp314-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl CPython 3.14 abi3 Linux glibc 2.17+ ARM64 Details
zodish-0.6.0-cp314-abi3-macosx_11_0_arm64.whl CPython 3.14 abi3 macOS 11.0+ ARM64 Details
zodish-0.6.0-cp314-abi3-macosx_10_12_x86_64.whl CPython 3.14 abi3 macOS 10.12+ x86-64 Details

Total release size: 3.6 MB

Release files / zodish-0.6.0.tar.gz

Download URL zodish-0.6.0.tar.gz
Size 269.8 kB
Tags Source
SHA-256 checksum
How to use checksums
b58e63400cf13b4b49ac2a3040295d5799141eac96c25f8c8b384d9593b99d3e
BLAKE2b-256 checksum
How to use checksums
ce1a00f82cbbcee3a89655f84248e220259f016bec37896c3c470c9c53796dc0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.

Transparency log

Release files / zodish-0.6.0-cp314-abi3-win_amd64.whl

Download URL zodish-0.6.0-cp314-abi3-win_amd64.whl
Size 337.9 kB
Tags CPython 3.14 Windows x86-64 abi3
SHA-256 checksum
How to use checksums
6f3e34136e10e3a5f98a6fdb93f18ecf7ddfb45ea2d9b19ec388b50be04f01d1
BLAKE2b-256 checksum
How to use checksums
7200b3bb36c508f6f2479b5cce9164e408816f927250f930804c1021ef90fd3b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.

Transparency log

Release files / zodish-0.6.0-cp314-abi3-musllinux_1_2_x86_64.whl

Download URL zodish-0.6.0-cp314-abi3-musllinux_1_2_x86_64.whl
Size 670.6 kB
Tags CPython 3.14 Linux musl 1.2+ x86-64 abi3
SHA-256 checksum
How to use checksums
8ddd48d1e731fa325b6a4099db0b8a1f5d93511b41799dcb8f60ae9f1348271d
BLAKE2b-256 checksum
How to use checksums
d357267a62d6dbd608684289c8082a81d52f22a797429037b5a19eac89e1ce06
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.

Transparency log

Release files / zodish-0.6.0-cp314-abi3-musllinux_1_2_aarch64.whl

Download URL zodish-0.6.0-cp314-abi3-musllinux_1_2_aarch64.whl
Size 623.7 kB
Tags CPython 3.14 Linux musl 1.2+ ARM64 abi3
SHA-256 checksum
How to use checksums
25c7cb8348cc4b116b9d2cd6313941a53946cb5b67172d763f20b65d446b98b4
BLAKE2b-256 checksum
How to use checksums
b7af9ea1604e947b08ce24b5d255886c0fb43e899545fc1c3ceae918b1bd02f1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.

Transparency log

Release files / zodish-0.6.0-cp314-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

Download URL zodish-0.6.0-cp314-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Size 458.0 kB
Tags CPython 3.14 Linux glibc 2.17+ x86-64 abi3
SHA-256 checksum
How to use checksums
ca30b5d2388864a8001b661300eb407a5b43ada72dd64464c89f6e4c004ac86d
BLAKE2b-256 checksum
How to use checksums
8faf2f0cf15e103787aff5d95b52d8f3444b1aff7468aee3c5d0f3bc8b813877
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.

Transparency log

Release files / zodish-0.6.0-cp314-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl

Download URL zodish-0.6.0-cp314-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Size 445.1 kB
Tags CPython 3.14 Linux glibc 2.17+ ARM64 abi3
SHA-256 checksum
How to use checksums
15645a3d71222e5ea497fd81ab6cc935225f654adcc702fc88eff0229e3a65e9
BLAKE2b-256 checksum
How to use checksums
a2118056b3d9e54083673212c950598c43f6e4994cc2896e8fc60aefa654ac04
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.

Transparency log

Release files / zodish-0.6.0-cp314-abi3-macosx_11_0_arm64.whl

Download URL zodish-0.6.0-cp314-abi3-macosx_11_0_arm64.whl
Size 411.8 kB
Tags CPython 3.14 abi3 macOS 11.0+ ARM64
SHA-256 checksum
How to use checksums
a46c4c0c977231fa3aedefaafb0ae418bda02ce84bfdb3ce86479e75015c11f9
BLAKE2b-256 checksum
How to use checksums
b7d3109390a772c40cae5ece6d847570a0cc957be063392d4768db657184cf6b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.

Transparency log

Release files / zodish-0.6.0-cp314-abi3-macosx_10_12_x86_64.whl

Download URL zodish-0.6.0-cp314-abi3-macosx_10_12_x86_64.whl
Size 432.9 kB
Tags CPython 3.14 abi3 macOS 10.12+ x86-64
SHA-256 checksum
How to use checksums
67112cff84fd433fab08b8944187867afb9d4d1ccd886809d1b81806b36b729d
BLAKE2b-256 checksum
How to use checksums
47222e870a17f3ab484a3fdca720948657a7d4718b819bca1d1b549c2b56a491
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.

Transparency log

Security for zodish 0.6.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 8 files in this release have been uploaded with provenance metadata.

Select from 8 files

Attestation for zodish-0.6.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page