Skip to main content

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 items is 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.json

array-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-formats
const 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 errors

Enforcing 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.jsonl

Two 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.

ChangeVerdictWhy
Add an optional fieldSafeOld records omit it and still validate
Relax a constraintSafeWidening an enum or range accepts a superset
Make a field requiredBreakingEvery existing record without it now fails
Change a field typeBreakingNo historical record can satisfy both types
Remove an enum valueBreakingRecords already carry the retired value
Set additionalProperties to falseRiskyAny extra field ever written becomes an error
Rename a fieldBreakingAn 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.

KeywordOn this siteEffect if used
typeCheckedRecord type and one level of property types
requiredCheckedTop-level presence only
propertiesPartialOne level deep, and only the type of each property
enum, pattern, formatIgnoredValue constraints are never applied
itemsIgnoredArray elements are not inspected at all
Nested schemasIgnoredSub-object rules below the first level are skipped
$refIgnoredReferences are never resolved
oneOf, if, then, additionalPropertiesIgnoredComposition 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.