Validating JSONL Records Against a Schema
Apply JSON Schema one line at a time and catch schema drift before it reaches production
Why Schema Validation Matters for JSONL
JSONL Is Schema-on-Read
JSONL has no header row, no declared column list, and no embedded type information. As the JSONL definition states, every line is an independent JSON document, and the format is satisfied the moment each line parses. Structure is something the reader assumes, not something the file guarantees.
Nothing stops line 40,000 from having a completely different shape than line 1. This file is entirely valid JSONL, and it will break almost any consumer that expects a consistent record:
{"user_id": 1, "name": "Alice", "active": true}
{"user_id": 2, "name": "Bob", "active": true}
{"userId": 3, "name": "Carol", "active": "yes"}
{"user_id": 4, "name": null}Four lines, four parseable JSON objects, three different record shapes, and one field that silently changed from boolean to string. A parser reports zero errors.
Schema Drift Is the Characteristic JSONL Failure
Malformed JSON is loud. A trailing comma or an unescaped quote fails immediately, at a known line, with a message you can look up in the JSONL error dictionary. Schema drift is the opposite: the data still parses, so nothing complains at read time.
Drift arrives from ordinary changes upstream. A producer renames a field. An identifier that was always numeric starts arriving as a string. An enum gains a value nobody documented. A field that was never null becomes nullable. Each change is reasonable in isolation, and none of them break the file format.
The break surfaces far downstream instead: a warehouse load rejects an entire batch on a type mismatch, a training run dies on record 900,000 after two hours, or a dashboard quietly drops rows it could not interpret. By then the file is days old. A schema check at ingest turns a distant, expensive failure into an immediate one with a line number attached.
Per-Line vs Whole-File Semantics
One Schema Describes One Record
This is the conceptual hurdle, and it is worth stating bluntly: JSON Schema describes a single JSON document. A JSONL file is not a single JSON document. It is a sequence of them.
So the schema you write describes one line, and you apply it once per line, independently. There is no keyword for "the file" and no way to express "line 3 must follow line 2". Cross-record rules such as identifier uniqueness are outside what a per-line schema can express, and need a separate pass.
schema.json (one record)
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"active": { "type": "boolean" }
}
}users.jsonl (many records)
{"id": "u1", "name": "Alice", "active": true}
{"id": "u2", "name": "Bob", "active": false}
{"id": "u3", "name": "Carol", "active": true}Why Generic Tooling Needs a Wrapper
Most "validate my JSON against a schema" tools read the entire input file and parse it as one document. Point one at a JSONL file and it fails on the second line, because two JSON values separated by a newline are not valid JSON. The tool is not wrong; you handed it the wrong unit.
There are two practical ways around it:
- Loop yourself. Read the file line by line, parse each line, and run the validator on each parsed object. This keeps line numbers and streams in constant memory.
- Wrap into an array. Convert the file into one JSON array and validate it with an array schema whose
itemsis your record schema. An off-the-shelf CLI then works unchanged, at the cost of memory and line numbers.
The array wrapper is convenient for small fixtures in CI. For anything large, loop.
Writing a Schema for a JSONL Record
Core Keywords
Four keywords carry most of the weight. type fixes the record as an object rather than an array or a bare value. required lists the field names that must be present. properties maps each field name to its own schema. additionalProperties decides whether unlisted fields are tolerated.
{
"type": "object",
"required": ["id", "event", "timestamp"],
"additionalProperties": false,
"properties": {
"id": { "type": "string" },
"event": { "type": "string" },
"timestamp": { "type": "string" },
"retries": { "type": "integer", "minimum": 0 },
"score": { "type": "number" },
"verified": { "type": "boolean" },
"notes": { "type": ["string", "null"] }
}
}Two details matter. A field listed under properties is optional unless it is also named in required, so the two lists are independent. And a field that may be absent is not a field that may be null; nullability needs a type union such as ["string", "null"].
Setting additionalProperties to false closes the record, turning a silently added upstream field into an error. That is right for an internal pipeline and often too strict for third-party data, where new fields appear without notice.
Constraining Values with enum, pattern, and format
Types alone accept far too much. A status field typed as a string accepts every string ever written. Narrow it.
{
"properties": {
"status": {
"type": "string",
"enum": ["pending", "active", "cancelled"]
},
"id": {
"type": "string",
"pattern": "^usr_[0-9a-f]{8}$"
},
"email": {
"type": "string",
"format": "email"
},
"created_at": {
"type": "string",
"format": "date-time"
}
}
}enum pins a closed set of allowed values. pattern applies a regular expression, which is how you enforce identifier prefixes and fixed-width codes. format names a well-known shape such as email, uri, uuid, or date-time.
One caveat before you rely on it: in JSON Schema, format is annotation-only by default. Many validators ignore it unless format assertion is explicitly enabled, so a bad email address can pass in a configuration you assumed was checking it. If a format matters, confirm your validator enforces it or back it up with a pattern.
Nested Objects and Arrays
Schemas nest the same way records do. A nested object is just another schema under properties, and every element of an array is described once by items.
{
"type": "object",
"required": ["id", "customer", "lines"],
"properties": {
"id": { "type": "string" },
"customer": {
"type": "object",
"required": ["id", "country"],
"properties": {
"id": { "type": "string" },
"country": { "type": "string", "pattern": "^[A-Z]{2}$" }
}
},
"tags": {
"type": "array",
"items": { "type": "string" },
"uniqueItems": true
},
"lines": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["sku", "qty"],
"properties": {
"sku": { "type": "string" },
"qty": { "type": "integer", "minimum": 1 }
}
}
}
}
}Note that required inside a nested object applies to that object, not to the record. A missing country is reported with the full path customer.country, which is what makes validator output actionable on a wide record.
Handling Mixed Record Types
oneOf with a const Discriminator
Event streams routinely mix record types in one file. Give every record a discriminator field, then branch on it with oneOf and pin each branch using const.
{
"type": "object",
"required": ["type"],
"oneOf": [
{
"properties": {
"type": { "const": "click" },
"element": { "type": "string" }
},
"required": ["type", "element"]
},
{
"properties": {
"type": { "const": "purchase" },
"amount": { "type": "number", "minimum": 0 }
},
"required": ["type", "amount"]
}
]
}Because each branch pins type to a different constant, exactly one branch can ever match, which is what oneOf requires. The downside is error quality: when a record fails, a validator usually reports that every branch failed and lists all of their errors, which is noisy to read.
if and then for Mostly-Shared Records
When records share most of their fields and differ in only one or two, if and then express the difference without duplicating the common part. Errors are also far more precise than the oneOf equivalent.
{
"type": "object",
"required": ["id", "kind"],
"properties": {
"id": { "type": "string" },
"kind": { "type": "string", "enum": ["email", "sms"] },
"address": { "type": "string", "format": "email" },
"phone": { "type": "string", "pattern": "^\\+[0-9]{8,15}$" }
},
"if": {
"properties": { "kind": { "const": "email" } }
},
"then": {
"required": ["address"]
},
"else": {
"required": ["phone"]
}
}Reach for oneOf when the record types are genuinely different documents sharing a file, and for if and then when they are one document with conditional fields. Mixing record types at all has real costs; the JSONL best practices guide covers when to split them into separate files instead.
Tooling You Can Run Today
Python: the jsonschema Library
The reference implementation for Python. Build a validator once, outside the loop, so the schema is compiled a single time rather than per record.
pip install jsonschema
# Optional: enables real "format" assertion for email, uri, date-time
pip install "jsonschema[format]"import json
from jsonschema import Draft202012Validator, FormatChecker
with open("schema.json") as f:
schema = json.load(f)
validator = Draft202012Validator(schema, format_checker=FormatChecker())
with open("events.jsonl") as f:
for line_num, line in enumerate(f, 1):
if not line.strip():
continue
record = json.loads(line)
for error in validator.iter_errors(record):
path = ".".join(str(p) for p in error.absolute_path) or "(root)"
print(f"line {line_num}: {path}: {error.message}")iter_errors yields every problem in a record instead of raising on the first, which matters when you are trying to understand how badly a file has drifted rather than just whether it has.
CLI: check-jsonschema
check-jsonschema is a command line front end for the same library, built for pre-commit hooks and CI. It validates one JSON document per file, so the array wrapper described above is what makes it work on JSONL.
pip install check-jsonschema
# A single JSON document, straight through
check-jsonschema --schemafile schema.json record.json
# JSONL: wrap the lines into one array, then validate with an array schema
python -c "import json,sys; print(json.dumps([json.loads(l) for l in open('events.jsonl') if l.strip()]))" > events-array.json
check-jsonschema --schemafile array-schema.json events-array.jsonarray-schema.json is simply your record schema placed under items:
{
"type": "array",
"items": { "$ref": "schema.json" }
}Errors come back with an array index rather than a line number, and the whole file is held in memory. A fine trade for a small fixture, a poor one for a multi-gigabyte export.
Node: Ajv
Ajv compiles a schema into a JavaScript function, which makes it very fast over long files. Formats live in a separate package, and strict mode is on by default.
npm install ajv ajv-formatsconst fs = require('fs');
const readline = require('readline');
const Ajv = require('ajv/dist/2020');
const addFormats = require('ajv-formats');
const schema = JSON.parse(fs.readFileSync('schema.json', 'utf8'));
// strict: true is the default and rejects unknown or misplaced keywords,
// which catches schema typos such as "requried" that would silently do nothing.
const ajv = new Ajv({ allErrors: true, strict: true });
addFormats(ajv);
const validate = ajv.compile(schema);
async function main() {
const rl = readline.createInterface({
input: fs.createReadStream('events.jsonl')
});
let lineNum = 0;
let bad = 0;
for await (const line of rl) {
lineNum++;
if (!line.trim()) continue;
const record = JSON.parse(line);
if (!validate(record)) {
bad++;
for (const e of validate.errors) {
console.error(`line ${lineNum}: ${e.instancePath || '(root)'} ${e.message}`);
}
}
}
console.log(`checked ${lineNum} lines, ${bad} invalid`);
process.exit(bad === 0 ? 0 : 1);
}
main();allErrors: true reports every failure in a record instead of stopping at the first. Leave strict mode on: it is the difference between a misspelled keyword being ignored and being reported, and a schema that silently checks nothing is worse than no schema at all.
A Streaming Validator with Line Numbers
A complete script that streams any size of file, separates parse errors from schema errors, and exits non-zero on failure so a pipeline can gate on it.
#!/usr/bin/env python3
"""validate_jsonl.py - validate every line of a JSONL file against a schema."""
import json
import sys
from jsonschema import Draft202012Validator, FormatChecker
MAX_REPORTED = 50
def main(schema_path, data_path):
with open(schema_path) as f:
validator = Draft202012Validator(json.load(f),
format_checker=FormatChecker())
total = parse_errors = schema_errors = reported = 0
with open(data_path, encoding="utf-8") as f:
for line_num, line in enumerate(f, 1):
if not line.strip():
continue
total += 1
try:
record = json.loads(line)
except json.JSONDecodeError as e:
parse_errors += 1
if reported < MAX_REPORTED:
reported += 1
print(f"line {line_num}: PARSE: {e.msg} at column {e.colno}")
continue
for error in validator.iter_errors(record):
schema_errors += 1
if reported < MAX_REPORTED:
reported += 1
path = ".".join(str(p) for p in error.absolute_path) or "(root)"
print(f"line {line_num}: SCHEMA: {path}: {error.message}")
print(f"\n{total} records | {parse_errors} parse errors | "
f"{schema_errors} schema errors")
return 1 if (parse_errors or schema_errors) else 0
if __name__ == "__main__":
if len(sys.argv) != 3:
print("usage: validate_jsonl.py schema.json data.jsonl", file=sys.stderr)
sys.exit(2)
sys.exit(main(sys.argv[1], sys.argv[2]))python validate_jsonl.py schema.json events.jsonl
line 41: SCHEMA: status: 'archived' is not one of ['pending', 'active', 'cancelled']
line 902: SCHEMA: (root): 'timestamp' is a required property
line 1150: PARSE: Expecting ',' delimiter at column 64
1204 records | 1 parse errors | 2 schema errorsEnforcing Validation in CI
Fail the Pipeline When Data Drifts
A validator nobody runs is documentation. The value comes from wiring it into a job that blocks something: a pull request, a nightly ingest, or the step that publishes a dataset. Because the script above exits non-zero on failure, any CI system can gate on it with no extra glue.
name: validate-data
on: [push, pull_request]
jobs:
schema:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install "jsonschema[format]"
- name: Validate sample records
run: python validate_jsonl.py schema.json data/samples.jsonlTwo placements are worth having. In the repository, validate a small committed fixture on every pull request. In the pipeline, validate the real file at ingest, before anything downstream reads it, and route failures to a dead letter file so one bad producer cannot halt the entire load. Keep the schema in version control next to the code that reads the data; a schema living in a wiki page drifts faster than the data does.
Evolving a Schema Without Breaking Readers
Safe, Risky, and Breaking Changes
Schemas change. What matters is whether the change invalidates records that already exist in files you cannot rewrite.
| Change | Verdict | Why |
|---|---|---|
| Add an optional field | Safe | Old records omit it and still validate |
| Relax a constraint | Safe | Widening an enum or range accepts a superset |
| Make a field required | Breaking | Every existing record without it now fails |
| Change a field type | Breaking | No historical record can satisfy both types |
| Remove an enum value | Breaking | Records already carry the retired value |
| Set additionalProperties to false | Risky | Any extra field ever written becomes an error |
| Rename a field | Breaking | An add plus a remove, with no overlap window |
The workable path for a required field is three steps: add it as optional, wait until every producer emits it, then promote it to required. A rename is the same shape: write both names for one retention period, migrate readers, then drop the old one.
Version the Records Themselves
When a breaking change is unavoidable, put a schema_version field in the record and let the validator branch on it. The version travels with the data, so a file written last year still declares which rules it was written under.
{"schema_version": 1, "id": "u1", "name": "Alice Smith"}
{"schema_version": 2, "id": "u2", "first_name": "Bob", "last_name": "Jones"}{
"type": "object",
"required": ["schema_version"],
"oneOf": [
{
"properties": { "schema_version": { "const": 1 } },
"required": ["schema_version", "id", "name"]
},
{
"properties": { "schema_version": { "const": 2 } },
"required": ["schema_version", "id", "first_name", "last_name"]
}
]
}Add the version field before you need it. Retrofitting one means guessing which version an unlabelled record belongs to, which is the exact ambiguity the field exists to remove.
What the NDJSON.com Validator Actually Checks
A Deliberately Limited Subset
The JSONL validator on this site has a JSON Schema checkbox, and it is worth being precise about what that checkbox does. It runs a small hand-written checker in the browser, not a conforming JSON Schema implementation. It supports type, required, and a single level of properties where only the type of each property is examined.
Everything else is ignored rather than reported. There is no warning that a keyword went unevaluated. So a clean result there means "nothing in the supported subset was violated" and is not proof that your data conforms to your schema.
| Keyword | On this site | Effect if used |
|---|---|---|
type | Checked | Record type and one level of property types |
required | Checked | Top-level presence only |
properties | Partial | One level deep, and only the type of each property |
enum, pattern, format | Ignored | Value constraints are never applied |
items | Ignored | Array elements are not inspected at all |
| Nested schemas | Ignored | Sub-object rules below the first level are skipped |
$ref | Ignored | References are never resolved |
oneOf, if, then, additionalProperties | Ignored | Composition and conditionals have no effect |
Use the on-site checkbox for what it is good at: a fast, private, no-install sanity check that every line is an object, that the fields you expect are present, and that their types have not drifted. For a conformance answer you can rely on, run Ajv or the Python jsonschema library against the same file. Both are full implementations, and both will report the keywords this page just told you the browser checker skips.