JSON Schema validator for Kotlin with comprehensive support for JSON Schema Draft 2019-09 and 2020-12.
This project is a playground for testing AI agents as a day-to-day tools in programming.
You can see the summaries and results from discussions with AI in the ./docs directory.
Most of the code refactoring were performed by AI agent with supervision and code review done by human.
- Features
- Tech Stack
- Getting Started
- Available Scripts
- Project Scope
- Project Status
- Documentation
- Performance
- Code Quality
- License
✅ Comprehensive Validation
- Full type validation (string, number, integer, boolean, object, array, null)
- String constraints (minLength, maxLength, pattern, format)
- Numeric constraints (minimum, maximum, multipleOf, exclusive bounds)
- Object constraints (properties, required, additionalProperties, patternProperties, dependencies)
- Array constraints (items, prefixItems, contains, uniqueItems, minItems, maxItems)
✅ Advanced Schema Features
- Schema references (
$ref) with local, fragment, and external schema support - Pluggable remote schema loading (
schemaLoadercallback) - Schema combiners (allOf, anyOf, oneOf, not)
- Conditional validation (if/then/else)
- Unevaluated properties and items (Draft 2020-12)
- Property name validation
- Dependent schemas and required properties
Core
- Language: Kotlin 2.4.0
- JVM: Java 21
- Build Tool: Gradle 9.1.0
Dependencies
kotlinx-serialization-json- JSON parsing and manipulationkotest- Testing frameworkktlint- Code style and quality
Package: org.ktson
- JDK 21 or higher
- Gradle 9.1.0 or higher (or use the included wrapper)
KtSON is not yet published to Maven Central or another public repository, so dependency coordinates alone will not resolve. Build a release into your local Maven repository first (the official test suite is not needed for this):
git clone https://github.com/rawo/ktson.git
cd ktson
git checkout v3.1.0 # a release tag; master may contain unreleased changes
./gradlew publishToMavenLocal # installs org.ktson:ktson:3.1.0 into ~/.m2/repositoryThen add mavenLocal() to your repositories and depend on that version.
repositories {
mavenLocal()
mavenCentral()
}
dependencies {
implementation("org.ktson:ktson:3.1.0")
}repositories {
mavenLocal()
mavenCentral()
}
dependencies {
implementation 'org.ktson:ktson:3.1.0'
}Maven reads ~/.m2/repository by default, so no extra repository is needed:
<dependency>
<groupId>org.ktson</groupId>
<artifactId>ktson</artifactId>
<version>3.1.0</version>
</dependency>import org.ktson.*
import kotlinx.serialization.json.*
fun main() {
// Create a JSON schema
val schemaJson = """
{
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "integer", "minimum": 0 }
},
"required": ["name", "age"]
}
"""
// Create instance data
val instanceJson = """
{
"name": "John Doe",
"age": 30
}
"""
// Validate
val validator = JsonValidator()
val result = validator.validate(instanceJson, schemaJson, SchemaVersion.DRAFT_2020_12)
when (result) {
is ValidationResult.Valid -> println("✓ Validation successful!")
is ValidationResult.Invalid -> {
println("✗ Validation failed:")
result.validationErrors.forEach { error ->
println(" - ${error.instanceLocation}: ${error.message}")
}
}
}
}import org.ktson.*
import kotlinx.serialization.json.*
val schema = buildJsonObject {
put("type", "string")
put("minLength", 3)
put("maxLength", 10)
}
val instance = JsonPrimitive("hello")
val validator = JsonValidator()
val jsonSchema = JsonSchema.fromElement(schema, SchemaVersion.DRAFT_2020_12)
val result = validator.validate(instance, jsonSchema)val validator = JsonValidator(enableMetaSchemaValidation = true)
val schema = JsonSchema.fromString(schemaJson, SchemaVersion.DRAFT_2020_12)
val result = validator.validateSchema(schema)
if (result is ValidationResult.Invalid) {
println("Invalid schema:")
result.validationErrors.forEach { println(" - ${it.message}") }
}val schema = """
{
"${'$'}defs": {
"address": {
"type": "object",
"properties": {
"street": { "type": "string" },
"city": { "type": "string" }
}
}
},
"type": "object",
"properties": {
"billing": { "${'$'}ref": "#/${'$'}defs/address" },
"shipping": { "${'$'}ref": "#/${'$'}defs/address" }
}
}
"""For more examples, see docs/REF_USAGE_EXAMPLES.md and src/main/kotlin/org/ktson/Example.kt.
validate(instance, schema) returns KtSON's own ValidationResult. To get the standard
JSON Schema output (2020-12 §12) instead, pass a format; the return type follows it:
val flag: FlagOutput = validator.validate(instance, schema, OutputFormat.Flag)
val basic: BasicOutput = validator.validate(instance, schema, OutputFormat.Basic)
val detailed: DetailedOutput = validator.validate(instance, schema, OutputFormat.Detailed)
println(basic.toJson())
// {"valid":false,"keywordLocation":"","instanceLocation":"","errors":[{"valid":false,
// "keywordLocation":"/properties/name/type","instanceLocation":"/name","error":"..."}]}Flagis{"valid": …}only;Basiclists every failing unit flat;Detailedkeeps theallOf/anyOf/oneOfhierarchy.result.toOutput(format)converts a result you already have.- Serialise with
toJson(). Encoding through theValidationOutputinterface would add a type discriminator that is not part of the standard. errortexts are KtSON's messages, which the standard does not fix; do not match on them.- Each failing unit carries
absoluteKeywordLocation: the canonical URI of the schema resource holding the keyword plus a JSON Pointer within it, following$ref,$dynamicRefand$recursiveRef(https://example.com/root#/$defs/p/minimumwherekeywordLocationis/properties/n/$ref/minimum), with the pointer percent-encoded as a URI fragment (a bbecomesa%20b). It needs an absolute$idorJsonSchema.fromString(json, version, baseUri); without either it is omitted. The same value is onValidationError.absoluteKeywordLocation. - There is no
Verboseformat: the next output-format draft removes it.
From Java, pass the token's INSTANCE; the conversion lives on ValidationOutputs:
BasicOutput basic = validator.validate(instance, schema, OutputFormat.Basic.INSTANCE);
DetailedOutput detailed = ValidationOutputs.toOutput(result, OutputFormat.Detailed.INSTANCE);The API is callable from Java without Companion or INSTANCE on the core types, and defaulted
parameters can be left out from the end:
JsonSchema schema = JsonSchema.fromString(schemaJson); // version defaults to 2020-12
JsonSchema older = JsonSchema.fromString(schemaJson, SchemaVersion.DRAFT_2019_09);
JsonValidator validator = new JsonValidator();
JsonValidator lenient = new JsonValidator(true, false); // format assertion off
JsonValidator remote = new JsonValidator(true, true, 1000, uri -> loadSchema(uri)); // schemaLoader as a lambda
ValidationResult result = validator.validate(instanceJson, schemaJson);
JsonElement value = JsonPointer.resolve(document, "/users/0/age");Constructor arguments are positional (enableMetaSchemaValidation, formatAssertion,
maxValidationDepth, schemaLoader), so setting a later one means passing the earlier ones too.
Kotlin objects such as OutputFormat.Basic and ValidationResult.Valid are reached through INSTANCE.
KtSON keeps no global state: every cache (remote schemas loaded through schemaLoader, $id
registrations, compiled patterns, numeric limits) belongs to one JsonValidator. That shapes how a
long-running service should use it:
- Dropping a validator frees everything it built. Once a
JsonValidatoris unreachable, its caches, the schemas it validated and the remote schemas it loaded can all be garbage-collected (ValidatorLifecycleTestchecks this). A service holding one validator per schema can retire a schema by dropping its validator. - A live validator keeps what it has seen. Its caches grow with the schemas it validates and
are not evicted, so reuse the parsed
JsonSchema(or the validator) rather than parsing the same schema again for every call through one shared validator. - Remote schemas are loaded once per validator. To pick up a changed remote schema, create a new validator.
- Pooling is up to the application. KtSON has no schema pool: how many schemas to keep, how much memory they may use and when to refresh them are application decisions, made by choosing which validators to keep.
KtSON validates complete JSON values; it does not parse streams. A validator cannot give a verdict on an incomplete document, so framing and buffering belong to whatever reads the stream: it collects one complete, self-contained unit (a record, a message, a request body) and passes it to KtSON. Memory is then bounded by the largest unit, not by the size of the stream.
For newline-delimited JSON, that is one parse and one validation per line:
val validator = JsonValidator()
val schema = JsonSchema.fromString(recordSchemaJson)
File("records.ndjson").useLines { lines ->
lines.filter { it.isNotBlank() }.forEachIndexed { index, line ->
val result = validator.validate(Json.parseToJsonElement(line), schema)
if (result.isInvalid) println("record $index: ${result.getErrors()}")
}
}A JsonValidator is thread-safe, so units can be validated in parallel with one shared validator.
Validation is CPU work: to keep it off an I/O or UI thread, run it on a worker thread or
Dispatchers.Default rather than expecting a non-blocking API from KtSON.
KtSON needs no GraalVM reachability metadata: the library uses no reflection, loads no resources at runtime and does no dynamic class loading, and its only runtime dependency, kotlinx-serialization-json, is used through compiler-generated serializers. Applications built as native images (Quarkus, Micronaut, Spring Native, CLI tools) should be able to use it without extra configuration. This is not tested in CI yet; reports of problems are welcome.
# Build the project
./gradlew build
# Compile only
./gradlew compileKotlin
# Create JAR
./gradlew jar# Run all tests (excludes performance tests for speed)
./gradlew test
# Run specific test suite
./gradlew test --tests "Draft201909ValidationTest"
./gradlew test --tests "OfficialTestSuiteRunner"
# Run performance tests (on demand)
./gradlew performanceTest
# Run all tests including performance
./gradlew test performanceTest# Check code style with ktlint
./gradlew ktlintCheck
# Auto-format code with ktlint
./gradlew ktlintFormat- ✅ JSON Schema Draft 2020-12 (primary support)
- ✅ JSON Schema Draft 2019-09 (full support)
| Feature Category | Status | Coverage |
|---|---|---|
| Type Validation | ✅ Complete | 100% |
| String Validation | ✅ Complete | 100% (including Unicode codepoints) |
| Numeric Validation | ✅ Complete | Exact Long/BigDecimal comparisons, including optional/bignum |
| Object Validation | ✅ Complete | 100% |
| Array Validation | ✅ Complete | 100% |
| Schema Combiners | ✅ Complete | 100% |
| Conditional Validation | ✅ Complete | 100% |
| References ($ref) | ✅ Complete | Local and external (via schemaLoader) |
| Unevaluated Keywords | ✅ Complete | 100% |
| Remote Schema Loading | ✅ Complete | Pluggable schemaLoader callback |
| Format Validation | ✅ Complete | All 19 formats, assertion mode only: email, idn-email, uri, uri-reference, uri-template, iri, iri-reference, date, time, date-time, duration, ipv4, ipv6, uuid, hostname, idn-hostname, json-pointer, relative-json-pointer, regex |
| Official Test Suite | ✅ 100% | 4,314/4,314 passing (includes optional/format and optional/bignum) |
See docs/IMPLEMENTATION_STATUS.md for detailed feature breakdown.
-
Stack Overflow Risk✅ RESOLVED- ✅ Global recursion depth limiting implemented (default: 1000 levels)
- ✅ Configurable via
maxValidationDepthconstructor parameter - See docs/STACK_OVERFLOW_RISK_ANALYSIS.md for historical analysis
-
Format Validation
- Assertion mode only (no annotation-only mode yet)
- IDNA validation (
hostname,idn-hostname,idn-email) passes the official suite, but decides code point validity from Unicode categories plus the RFC 5892 §2.6 exception list rather than the IDNA derived-property table, which the JDK does not expose.Joining_Typeis approximated by script for the CONTEXTJ rule and the Virama set is a fixed list, so both drift with new Unicode versions. Exotic code points may be misjudged; everyday names are not affected.
-
Output Formats
- No annotations in output (#5).
absoluteKeywordLocationis omitted when the schema has neither an absolute$idnor a base URI, as JSON Schema 2020-12 §12.3.3 allows.
3.1.0 only adds API, so it is a drop-in replacement: no code changes and no changed verdicts.
- Standard output formats:
validate(instance, schema, OutputFormat.Basic)andresult.toOutput(format)return JSON Schema 2020-12 §12 output. - Java-friendly API: static factories on
JsonSchemaandJsonPointer, and overloads that let Java omit defaulted arguments.
3.0.0 has two breaking changes: one to the error API, one to numeric verdicts.
Error locations are RFC 6901 JSON Pointers. ValidationError.instanceLocation replaces
path as the primary field: the root is "", nested properties use /user/age, and array
elements use /users/0. Tokens escape ~ as ~0 and / as ~1; / identifies a member with
an empty key. propertyNames failures point to the owning object and name the offending key in
the message. Nested causes and toString() use these canonical locations too.
The deprecated error.path getter derives the old dotted/bracket notation for display only.
This projection loses information: /a.b and /a/b both become a.b, and numeric object keys
are indistinguishable from array indices. Use instanceLocation for navigation:
val error = ValidationError(instanceLocation = "/users/0/age", message = "Expected an integer")
val moved = error.copy(instanceLocation = "/users/1/age")
val failingValue = JsonPointer.resolve(instance, error.instanceLocation)Migrate named constructor and copy(path = ...) arguments to instanceLocation = ..., passing
a JSON Pointer. Positional construction and the first destructured component also use pointer
semantics now. See the instance-location implementation.
Numbers are compared exactly. Numeric comparisons use exact Long/BigDecimal arithmetic
instead of Double. This changes verdicts for large integers, high-precision fractions and
near-multiples previously accepted by the floating-point tolerance; valid large integers such as
1e400 are now accepted as integers. Numeric strings remain distinct from numbers.
See the numeric precision implementation and benchmarks.
Pin 2.0.0 if you need the old verdicts.
The public API is unchanged — same classes, same constructor parameters, same methods, so 2.0.0 is a drop-in replacement at compile time. The major version reflects changed validation verdicts:
- Format validation is much stricter. Of 879 official format cases, 112 now answer differently
(100 valid → invalid, 10 invalid → valid, 2 that used to throw). Every change moves toward the
official test suite, which 1.0.0 failed in 220 places. Affected most:
hostnameandidn-hostname(A-labels are now decoded and checked),uri-template(full RFC 6570 grammar), theuri/irifamily (percent-encoding, control characters, authority rules), andregex(ECMA 262 rather than Java syntax). This applies on default settings, sinceformatAssertiondefaults totrue. patternno longer throws on[]and[^]. 1.0.0 let aPatternSyntaxExceptionescapevalidate(); these are valid ECMA 262 and now validate normally.- Error content changed.
ValidationError.schemaPathwas alwaysnulland is now always populated with the keyword location, sotoString()output differs. Messages render numbers as written (maximum 10, not10.0) and quote the offending value. Do not assert on message text. - Dependencies: Kotlin 2.4.0, kotlinx-serialization-json 1.8.1, coroutines 1.10.2.
If a value your data relies on now fails, it is worth checking against the official suite before assuming a regression — but pin 1.0.0 if you need the old verdicts.
Version: 3.1.0
Status: ✅ Stable
-
Custom Tests: 852 tests (100% passing)
- Draft 2019-09: 47 tests
- Draft 2020-12: 54 tests
- Edge cases & thread safety: 39 tests
- Depth limit protection: 14 tests
- URI resolver: 25 tests
- Format validation: 294 tests
- Error messages: 32 tests
- Numeric precision and concurrent numeric validation: 88 tests
- Instance locations, legacy access and JSON Pointer resolution: 117 tests
- Output formats: 40 tests
- Absolute keyword locations: 45 tests
- Reference resolution (percent-encoding, base URIs,
$idregistration): 26 tests - Keyword-named properties (
$id,$anchor,$dynamicAnchor): 7 tests unevaluated*across schema resources: 6 tests- Validator lifecycle (garbage collection): 2 tests
- Output-tests runner file discovery: 4 tests
- Java interop for output formats: 2 tests
- Java interop for the core API: 10 tests
-
Official JSON Schema Test Suite: 4,314 tests
- ✅ Passing: 4,314 (100%)
- ❌ Failing: 0
- Includes the
optional/formatfiles andoptional/bignum.jsonfor both drafts;vocabulary.json,infinite-loop-detection.jsonand the rest ofoptional/are not run yet
-
Official output-tests: 6 (Basic format, both drafts;
readOnly.jsonneeds annotations and is not run), plus 2 checks of KtSON's Basic output for a$refschema against the official output schema -
Performance Tests: 9 tests (100% passing)
- Tested with schemas up to 50MB
- Tested with data up to 20MB
- Concurrent validation verified
- Three numeric benchmarks cover integer, decimal and wide-integer validation
- ✅ Errors carry a keyword location (
ValidationError.schemaPath), e.g./properties/n/$ref/minimum - ✅ Errors carry an instance location (
ValidationError.instanceLocation), e.g./users/0/age - ✅ Numbers are reported as written —
maximum 10, notmaximum 10.0 - ✅ Messages quote the offending value for
pattern,formatand the length bounds - ✅ All 19 formats pass the official
optional/formatsuite (1,748 assertions that previously did not run) - ✅ IDNA validation for
hostname/idn-hostname/idn-email: Punycode round-tripping, UTS 46 mapping, RFC 5892 contextual rules and the RFC 5893 Bidi rule - ✅ One RFC 3986/3987 parser behind
uri,uri-reference,iriandiri-reference - ✅ RFC 6570 grammar for
uri-template; ECMA 262 syntax rules forregexandpattern - ✅ Remote schema loading implemented (pluggable
schemaLoadercallback) - ✅ 100% official test suite pass rate (4,314/4,314 tests)
- ✅ Exact numeric comparisons for bounds, integer classification,
multipleOfand numeric equality - ✅
unevaluatedPropertiesandunevaluatedItemsimplemented - ✅
$dynamicRef/$dynamicAnchorfull dynamic scope resolution - ✅
$recursiveRef/$recursiveAnchordynamic recursion - ✅ Stack overflow protection (configurable depth limiting)
- ✅ Converted from async to synchronous API
- ✅ Package migrated from
com.ktsontoorg.ktson
Before 1.0 Release:
-
Implement recursion depth limiting✅ COMPLETED -
Implement✅ COMPLETEDunevaluatedPropertiesandunevaluatedItems -
Remote schema reference support✅ COMPLETED -
Add more format validators (hostname, idn-email, iri, regex)✅ COMPLETED (all 19 formats, verified against the officialoptional/formatsuite) -
Improve error messages✅ COMPLETED (keyword locations, literal-accurate numbers, offending values)
Future Enhancements:
- RFC 6901 instance locations in errors (
instanceLocation, deprecated legacypathgetter) - Standard output formats: flag, basic, detailed
-
absoluteKeywordLocationin errors and standard output - Annotations in standard output (#5)
- Arbitrary-precision numbers — exact comparisons beyond 2^53
- No global state: dropping a validator frees its schemas and caches (tested); schema pooling is left to applications
Comprehensive documentation is available in the docs directory:
- FEATURES.md - Complete feature list
- REF_USAGE_EXAMPLES.md - Reference usage examples
- Example.kt - Code examples
- TESTING.md - Testing approach and methodology
- OFFICIAL_TEST_SUITE_RESULTS.md - Detailed test results
- PERFORMANCE_TEST_RESULTS.md - Performance benchmarks
- PERFORMANCE_TESTING_SUMMARY.md - Performance test implementation
- STACK_OVERFLOW_RISK_ANALYSIS.md - Recursion depth analysis (
⚠️ Important) - GRAPHEME_CLUSTER_ISSUE.md - Unicode handling details
- IMPLEMENTATION_STATUS.md - Feature implementation tracking
- MIGRATION_COMPLETE.md - Async to sync migration summary
- ASYNC_TO_SYNC_MIGRATION.md - Detailed migration guide
- PACKAGE_MIGRATION.md - Package refactoring documentation
- VERSION_UPGRADE.md - Version upgrade summary
See docs/README.md for a complete documentation index.
KtSON demonstrates excellent performance with large and complex schemas:
| Test Case | Schema Size | Data Size | Execution Time | Memory Used |
|---|---|---|---|---|
| Nested Objects | 25.2 MB | 10.2 MB | 84ms | 73 MB |
| Large Arrays | 905 KB | 80 KB | 7ms | 1 MB |
| Complex Properties | 534 KB | 205 KB | 6ms | 4 MB |
| Combiners | 221 KB | 38 KB | 4ms | 1 MB |
| Pattern Properties | 63 KB | 148 KB | 48ms | 175 MB |
| Concurrent (10 threads) | 50 MB | 20 MB | 19ms avg | 392 MB |
Key Characteristics:
- Fast: Most validations complete in single-digit milliseconds
- Scalable: Handles schemas up to 50MB efficiently
- Thread-safe: Zero synchronization overhead
- Memory efficient: Proportional to schema complexity
See docs/PERFORMANCE_TEST_RESULTS.md for detailed benchmarks.
KtSON follows strict code quality standards:
- Code Style: Enforced with Ktlint 1.7.1
- Style Guide: IntelliJ IDEA default Kotlin conventions
- Test Coverage: 2,500+ tests across multiple suites
- Thread Safety: Verified with concurrent validation tests
- Documentation: Comprehensive inline documentation and separate docs
Check code style:
./gradlew ktlintCheckAuto-format code:
./gradlew ktlintFormatThis project is licensed under the MIT License - see the LICENSE file for details.
MIT License
Copyright (c) 2024 KtSON Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Note: This is a pre-release version. The validator passes 100% of the official JSON Schema Test Suite. Recursion depth protection is implemented (default limit: 1000). See STACK_OVERFLOW_RISK_ANALYSIS.md for details on depth limiting.