JSON Validation in CI: A Pipeline with jsonlint, ajv, and jq
Bottom line: Syntax validation proves a file parses; schema validation proves fields follow the contract. In CI, what matters is unambiguous exit codes, error locations and repair paths.

Bottom line: in CI, let jq empty handle syntax, ajv-cli handle structure (check-jsonschema for multi-format teams), and Prettier handle style; chain them by layer and stop at the first failure.
Passing the parser in your local editor does not mean the file belongs in CI; passing CI does not mean the fields match the contract either.
1. Separate syntax validation from schema validation
The phrase "JSON validation" is used far too broadly, which is why teams often pick one tool to solve two different problems:
| Layer | Question answered | Typical tools | What failure means |
|---|---|---|---|
| Syntax | Is this file valid JSON? | jq empty, jsonlint, python -m json.tool |
Brackets, commas, quotes, or encoding are broken |
| Schema | Do fields, types, and required properties match the contract? | ajv-cli, check-jsonschema |
The file parses, but the business structure is wrong |
| Style | Are indentation and key order consistent? | Prettier, jq -S |
Formatting is inconsistent and diffs are noisy |
These layers cannot replace one another. A file that passes syntax can still fail schema: {"price": "12.5"} is valid JSON, but if the schema requires price to be a number, it is not acceptable. Conversely, no matter how complete the schema is, a file missing a closing brace will never compile against it.
So the pipeline should run syntax first, structure second, and style last. Stop as soon as a layer fails, otherwise a parse error disguises itself as a field error.
2. Syntax layer: jq empty is the minimum-dependency option
If jq is already in the CI image, syntax validation costs almost nothing:
jq empty data.json
jq empty produces no output; it only parses the file. It exits with 0 on success and a non-zero code plus line/column information on failure. That makes it lighter than pulling in a full Node dependency tree, and a good first gate.
When batch-validating JSON files in a repository, exclude node_modules, build output, and third-party caches:
find . -name '*.json' \
-not -path './node_modules/*' \
-not -path './dist/*' \
-print0 | xargs -0 -n1 jq empty
-print0 and xargs -0 handle spaces in file names, while -n1 ensures one file per invocation so the error message identifies the culprit. Do not pass hundreds of files to a single process; error messages will overwrite each other.
jsonlint is another common choice, usually invoked as npx jsonlint -q data.json, where -q prints only errors. It reports line and column numbers, but its maintenance is less active than jq, and different versions tolerate BOM and comments differently. Treat it as a more readable syntax checker, not as a schema validator.
3. Schema layer: the basics of ajv-cli validate
Ajv is the most widely used schema validator in the JavaScript ecosystem, and ajv-cli exposes it as a command-line tool:
ajv validate -s schema.json -d data.json
-s points to the schema file and -d to the data being validated. On failure the process exits non-zero, so CI fails immediately. Real projects usually add two flags:
npx ajv-cli validate \
-s schemas/order.json \
-d 'fixtures/orders/*.json' \
--strict=false \
--errors=text
--strict=false helps with older schemas: strict mode treats unknown keywords and suspicious required declarations as errors, which many legacy schemas fail. --errors=text produces human-readable output, while the default JSON structure is better for machines. Choose based on who consumes the result.
The schema draft version must match as well. Ajv major versions support different drafts by default, and ajv-cli can select one with --spec=draft7, --spec=draft2019, or --spec=draft2020. If a schema declares $schema: "https://json-schema.org/draft/2020-12/schema" but the CLI compiles it as draft-07, some keywords are silently ignored. For more on schema authoring and version differences, see Getting started with JSON Schema.
Format validation is another frequent requirement. The format keyword is annotation-only by default; to make email, date-time, and uri actually enforce anything, load ajv-formats:
npx ajv-cli validate -c ajv-formats -s schema.json -d data.json
This step is easy to miss, and the result is that an email field accepts not-an-email. Before shipping, feed the pipeline one deliberately invalid record; that proves the rule is active far better than reading the documentation ten times.
check-jsonschema: multiple formats and friendlier errors
If the team handles YAML, TOML, or GitHub Actions configuration in addition to JSON, check-jsonschema is more convenient:
check-jsonschema --schemafile schemas/config.json config/app.json
check-jsonschema --schemafile schemas/pipeline.json .github/workflows/ci.yml
Its practical advantages:
- Native YAML support, which suits files that are "JSON-shaped but written in YAML", such as Kubernetes manifests and CI configuration.
- Errors include the file path, line number, and field path, keeping location costs low.
- A pre-commit hook is provided, so integration is cheap.
- It can pull common schemas from SchemaStore, but also accepts local files, avoiding a network dependency.
In cross-language teams, Python is often already present, so installing and upgrading check-jsonschema is lighter than maintaining a Node toolchain. Let jq handle syntax and this tool handle structure; the combination has very low maintenance overhead.
4. Exit codes are the only trustworthy CI signal
No matter how polished the output is, CI only reads exit codes. Three traps are common:
| Symptom | Cause | Fix |
|---|---|---|
| Errors exist but CI is green | The command ends with ` | |
| No errors but CI is red | diff treats "differences found" as failure |
Distinguish "difference" from "execution error" |
| Error output is truncated | Parallel multi-file output overwrites itself | Run serially or print per file |
Remember jq exit-code semantics: normal parsing returns 0; with -e, an expression result of false or null returns 1; usage errors and missing files return 2; script compilation errors return 3. Schema tools generally use 0 for success, 1 for validation failure, and 2 for tool or argument errors. When writing a wrapper, do not collapse every non-zero code into "the data is wrong", or an environment failure will be misdiagnosed as a content problem.
5. pre-commit: catch problems before the commit
The value of pre-commit is not "one more check"; it is giving developers feedback in a minute instead of after a ten-minute CI run. A minimal configuration looks like this:
repos:
- repo: https://github.com/python-jsonschema/check-jsonschema
rev: 0.33.0
hooks:
- id: check-jsonschema
files: ^configs/.*\.json$
args:
- --schemafile
- schemas/config.json
- repo: local
hooks:
- id: jq-syntax
name: jq syntax check
entry: jq empty
language: system
files: ^configs/.*\.json$
Two details matter:
filesshould match only the directories that need validation. Do not run hooks againstnode_modulesor generated files.- The local hook assumes
jqis installed on the developer machine. Document that requirement, or switch to a containerized hook.
If the project already uses Husky and lint-staged, combine them directly:
{
"lint-staged": {
"configs/*.json": ["jq empty", "prettier --check"]
}
}
Remember that lint-staged passes a list of staged files, so the command must accept multiple paths. Both jq empty and prettier --check do. Do not let a hook scan the entire repository; commits will become noticeably slower.
6. GitHub Actions: a pipeline you can copy
The workflow below performs syntax validation, schema validation, and preserves the report on failure:
name: json-validate
on:
push:
paths:
- 'schemas/**'
- 'fixtures/**'
pull_request:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Syntax check
run: |
find fixtures -name '*.json' -print0 | xargs -0 -n1 jq empty
- name: Schema check
run: |
npx --yes ajv-cli validate \
-s schemas/order.json \
-d 'fixtures/orders/*.json' \
--strict=false \
--errors=text
The paths filter prevents documentation-only changes from triggering validation. npx --yes suits repositories without pinned dependencies; production projects should add ajv-cli to devDependencies and pin the version. Without a lock, an upstream release that changes the default draft or strict mode can turn the pipeline red overnight.
7. Error location: from line numbers to field paths
Half the value of a validator is detection; the other half is location. Learn to read three kinds of output:
- Syntax errors:
jqreports line and column, often with a nearby snippet. Fix brackets and commas first; do not suspect encoding immediately. - Schema errors: Ajv's
instancePathpoints at the data location (for example/items/0/price),schemaPathpoints at the rule (for example#/properties/items/items/properties/price/type), andkeywordnames the violated constraint. - Format errors: when a
formatcheck fails, the message often only says "must match format "email"". Combine it withinstancePathto identify the field.
Paste instancePath into the investigation record; it is far more useful than a wall of JSON. For arrays, the numeric index also tells you which record failed. If the business key is more intuitive, add a mapping line afterward, such as items[0].id = ORD-042.
Common schema mistakes, such as overusing additionalProperties: false or writing oneOf branches that actually overlap, are covered in Common JSON Schema generation pitfalls. A wrong validation rule is more dangerous than no validation, because it creates a false sense of safety.
8. Tool selection table
| Need | Recommended tool | Why | Watch out for |
|---|---|---|---|
| Syntax only | jq empty |
Zero dependencies, fast, clear exit codes | Spaces in file names during batch runs |
| Friendlier line/column errors | jsonlint |
Readable output | Maintenance activity is limited |
| JSON Schema validation | ajv-cli |
Mature ecosystem, structured errors | Align draft versions and load format support |
| Mixed JSON and YAML repository | check-jsonschema |
Multi-format, pre-commit friendly | Schema source and network access |
| Fast feedback before commit | pre-commit / lint-staged | Only processes staged files | Hooks must accept multiple paths |
| Consistent formatting | Prettier | Widely adopted | Use --check when you do not want to rewrite files |
One final recommendation: verify that the pipeline actually fails by feeding it one deliberately invalid record first. The biggest risk is not a complicated configuration; it is a configuration that never takes effect. Treating "it can fail" as the acceptance criterion is more reliable than treating "it passes" as one. For the wider command-line toolchain, continue with Editors, command line, and automation.
FAQ
Should I use jq empty or jsonlint?
Prefer jq empty in CI: zero dependencies, fast startup, and clear exit-code semantics. Use jsonlint when you need friendlier line/column messages or are investigating locally. Both perform syntax validation only; neither checks field structure.
Why does ajv report an unknown keyword?
First confirm that the schema's $schema draft matches the version the CLI uses. Many "unknown keyword" errors are new-draft keywords encountered by an older compiler. If the versions match, check for a typo. --strict=false can keep legacy schemas working, but do not rely on it long-term to hide real problems.
Schema validation passes, but the API still fails. Why?
A schema validates structure, not business semantics. For example, status may be a string with a legal enum, while the state machine forbids moving directly from paid to refunded. Rules like that belong in code or database constraints, not in JSON Schema.
How do I locate the failing field quickly?
Read the instancePath or line number from the tool output. Ajv's instancePath is a JSON Pointer that points directly at the data; check-jsonschema reports file and line. Record the path and map it to the business key; locating the issue usually takes minutes.
Should pre-commit run a full validation?
No. pre-commit should process only staged files to keep commits fast. Full scans belong in CI or scheduled jobs. The division is simple: local runs favor speed, CI favors completeness.