You know what you want to do. You want to set one value in a config file. The problem is finding it — three levels deep in a YAML you didn't write, in a key named something slightly different from what you expected, in a file that's 400 lines long and growing. So you open it in an editor, search, scroll, edit, save. It works. Then you need to do it again in CI, on a different file, with a value that comes from an environment variable. Now it's a shell script. Now it's fragile.
The deeper frustration isn't the individual file. It's that the moment you want to go slightly further — preview what would change before writing, confirm a file is already at the right value, apply the same change across a stream of Kubernetes manifests — you hit the ceiling of whatever tool you're using. jq is JSON-only and has no in-place flag. yq handles YAML but can't write TOML and gives you no way to detect drift. You end up gluing three tools together with shell, and the glue breaks in ways that are hard to test and harder to debug.
nesdit takes a different approach: one binary, one query syntax (jq-style, so you already know it), three formats. You describe what the document should look like — not how to get there. nesdit applies it, shows you a diff if you want one, tells you via exit code whether anything actually changed, and writes atomically if you say so. The same command that edits one file in your terminal gates a CI step, validates a stream of manifests, or updates a batch of configs — without any shell glue in between.
Install
# Go
go install github.com/sdkks/nesdit/cmd/nesdit@latest
# Homebrew
brew tap sdkks/tap && brew install sdkks/tap/nesdit
Pre-built binaries (Linux amd64/arm64, macOS arm64) are on the releases page.
The Basics
nesdit reads a file (or stdin), applies a jq-style query, and writes the result. Format is auto-detected from the file extension.
# Print to stdout — file untouched
nesdit config.json --query '.env = "production"'
# Edit in-place (atomic write via temp file + rename)
nesdit -i config.json --query '.env = "production"'
# Preview changes without writing (unified diff to stdout)
nesdit -n config.json --query '.env = "production"'
The same flags work for YAML and TOML — no format-specific commands to remember.
JSON: Key Order Preserved, Compact Output
$ echo '{"env":"staging","replicas":1,"debug":false}' | nesdit --query '.env = "production" | .replicas = 3'
{"env":"production","replicas":3,"debug":false}
Key order is preserved — env stays first, debug stays last. nesdit always outputs compact JSON (no newlines between keys). If you hand it pretty-printed JSON it comes back compact — that's the current behaviour, worth knowing before you run it on a formatted file.
Compare with jq, which always pretty-prints by default:
$ echo '{"env":"staging","replicas":1}' | jq '.env = "production"'
{
"env": "production",
"replicas": 1
}
And jq has no -i flag — in-place editing requires the workaround:
jq '.env = "production"' config.json > tmp.json && mv tmp.json config.json
# If jq fails, tmp.json is empty and the original is gone.
nesdit's -i writes atomically:
nesdit -i config.json --query '.env = "production"'
# Atomic: temp file in same dir → rename over target.
# On failure: original is untouched.
YAML: Key Order Preserved, Merge Keys Work
# values.yaml
zebra: z-value
apple: a-value
mango: m-value
$ nesdit values.yaml --query '.apple = "updated"'
zebra: z-value
apple: updated
mango: m-value
Key order is preserved exactly. This matters when you're diffing manifests and don't want spurious reordering in the git log.
Anchor merge keys (<<:) are applied at decode time
As of v0.3.2, nesdit correctly applies YAML merge keys. This is particularly useful for Helm values files and Kubernetes configs that use anchors for shared defaults:
# config.yaml
defaults: &defaults
replicas: 1
image: nginx
web:
<<: *defaults
replicas: 3
$ nesdit config.yaml --query '.web.image = "nginx:1.25"'
defaults:
replicas: 1
image: nginx
web:
replicas: 3
image: nginx:1.25
The <<: merge is resolved at decode — web.image is accessible and writable. The <<: key does not appear in the output.
What nesdit doesn't preserve in YAML
Comments and quoted strings are both stripped during the parse-serialize cycle:
# input
name: myapp
replicas: 1 # scale this
cpu: "100m"
$ nesdit input.yaml --query '.replicas = 3'
name: myapp
replicas: 3
cpu: 100m
The comment is gone. The quotes on cpu are gone. This is consistent with how most YAML parsers behave — the YAML spec treats 100m and "100m" as the same string value. If your workflow depends on comment preservation, nesdit is not the right tool for that file.
For files without comments — which is the majority of machine-managed config and Kubernetes manifests — nesdit's output is clean and minimal:
# deploy.yaml
name: myapp
replicas: 1
image: nginx:1.24
$ nesdit -i deploy.yaml --query '.replicas = 3 | .image = "nginx:1.25"'
# deploy.yaml after
name: myapp
replicas: 3
image: nginx:1.25
Only the touched values changed.
TOML: Write Support (Flat Keys Work Best)
This is where the ecosystem gap is most obvious. yq (mikefarah) can read TOML but explicitly cannot write it back. kislyuk/yq's tomlq drops comments. dasel strips explicit string quotes.
nesdit can write TOML. For flat (no-section) TOML, it preserves key order and only changes the targeted value:
# config.toml
version = "1.0"
name = "project"
debug = false
$ nesdit config.toml --query '.version = "2.0"'
version = "2.0"
name = "project"
debug = false
For sectioned TOML ([package] style), nesdit currently rewrites sections to inline-table syntax — this is a known trade-off documented in the project:
# input: Cargo.toml
[package]
name = "my-crate"
version = "0.1.0"
[dependencies]
tokio = "1.0"
$ nesdit Cargo.toml --query '.package.version = "0.2.0"'
package = {name = "my-crate", version = "0.2.0"}
dependencies = {tokio = "1.0"}
Semantically correct, structurally different. The inline-table form guarantees stable round-trips regardless of key order — but it is not idiomatic for Cargo.toml or pyproject.toml. For flat TOML configs (version = "x" style) it works cleanly. For sectioned TOML, wait for a future release or file a request.
Human-readable TOML output with --pretty
The --pretty flag emits TOML with blank lines between top-level entries and multi-line arrays:
$ printf '{"name":"myapp","tags":["go","toml","cli"]}\n' | nesdit --format json --output-format toml --pretty
name = "myapp"
tags = [
"go",
"toml",
"cli",
]
--pretty is silently ignored for JSON and YAML output.
Dry-Run and Drift Check: The CI/CD Workflow
This is where nesdit earns its place in a pipeline.
--dry-run (-n): preview before writing
$ nesdit -n helm/values.yaml --query '.image.tag = "v2.0.0"'
--- helm/values.yaml
+++ helm/values.yaml
@@ -1,6 +1,6 @@
image:
repository: nginx
- tag: v1.9.0
+ tag: v2.0.0
pullPolicy: IfNotPresent
service:
port: 80
Exits 0 whether or not there's a diff. Use this in CI to log what a release would change before actually changing it.
--check: gate on drift via exit code
nesdit --check config/values.yaml --query '.image.tag = "v2.0.0"'
# Exit 0 — already up to date
# Exit 1 — error (parse failure, bad query, etc.)
# Exit 2 — would change (drift detected)
Exit code 2 is reserved exclusively for drift, making it safe to branch on in scripts:
nesdit --check helm/values.yaml --query '.image.tag = "v2.0.0"'
case $? in
0) echo "Already at v2.0.0, nothing to do" ;;
2) echo "Drift — applying update" && nesdit -i helm/values.yaml --query '.image.tag = "v2.0.0"' ;;
*) echo "Error" && exit 1 ;;
esac
Neither yq nor jq provides this — you'd have to extract the value, compare it in shell, and then run the edit as a separate step.
Pre-commit hook
# .pre-commit-hooks.yaml
- id: nesdit-check
name: Detect config drift
language: system
entry: nesdit --check --query '.'
files: ^config/.*\.(yaml|json|toml)$
pass_filenames: true
Run the identity query (.) against committed config files. If someone hand-edits a file in a way that would change its canonical form, the hook catches it before the commit lands.
Multi-Document YAML via Stdin
Kubernetes manifests are often multi-document YAML — multiple resources in one file, separated by ---. nesdit handles multi-doc YAML in stdin stream mode:
cat manifests.yaml | nesdit --format yaml \
--where '.kind == "Deployment"' \
--query '.spec.replicas = 3'
The --where flag applies your query only to documents matching the predicate. Non-matching documents (like Services) pass through unchanged.
Input (manifests.yaml):
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
spec:
replicas: 1
template:
spec:
containers:
- name: app
image: frontend:v1.0
---
apiVersion: v1
kind: Service
metadata:
name: frontend
spec:
port: 80
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
spec:
replicas: 2
template:
spec:
containers:
- name: app
image: backend:v1.0
Command:
cat manifests.yaml | nesdit --format yaml \
--where '.kind == "Deployment" and .metadata.name == "frontend"' \
--query '.spec.template.spec.containers[0].image = "frontend:v2.0"'
Output:
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
spec:
replicas: 1
template:
spec:
containers:
- name: app
image: frontend:v2.0
---
apiVersion: v1
kind: Service
metadata:
name: frontend
spec:
port: 80
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
spec:
replicas: 2
template:
spec:
containers:
- name: app
image: backend:v1.0
Only the frontend Deployment was touched. The Service and backend Deployment are byte-for-byte identical.
Note: nesdit -i manifests.yaml only supports single-document YAML — multi-doc requires stdin. To write multi-doc edits back to a file, redirect:
cat manifests.yaml | nesdit --format yaml \
--where '.kind == "Deployment"' \
--query '.spec.replicas = 3' > manifests.yaml
Shell Variable Injection
--arg: string values
VERSION="v2.0.0"
nesdit -i values.yaml --arg version="$VERSION" --query '.image.tag = $version'
--arg always binds as a string. Numbers passed this way stay strings.
--argjson: typed values
nesdit -i deploy.yaml --argjson replicas=5 --query '.spec.replicas = $replicas'
# .spec.replicas becomes the number 5, not the string "5"
nesdit -i config.yaml --argjson enabled=true --query '.feature.enabled = $enabled'
# boolean
nesdit -i config.yaml --argjson tags='["web","api"]' \
--create-missing --query '.service.tags = $tags'
# array
--argjson parses the value as JSON — numbers, booleans, arrays, and objects all work. If the value isn't valid JSON, nesdit exits with a clear error:
$ nesdit config.json --argjson v='not-json' --query '.v = $v'
nesdit: error: arg.decode: --argjson v: expected JSON, got "not-json"
Batch Updates
Pass multiple files to update them all in one run:
nesdit -i config/a.yaml config/b.yaml config/c.yaml --query '.replicas = 5'
# nesdit: info: batch.summary: 3 changed, 0 unchanged, 0 errored
By default (--strict), if any file fails, nothing is written. This is the safe default for pipelines where partial updates are worse than no update.
Use --keep-going to process all files and report errors at the end:
nesdit -i config/*.yaml --query '.replicas = 5' --keep-going
# nesdit: error: config/broken.yaml: query.runtime: ...
# nesdit: info: batch.summary: 4 changed, 0 unchanged, 1 errored
# Exit code: 1
A Complete CI/CD Example
# .github/workflows/release.yml
on:
push:
tags: ['v*']
jobs:
bump-chart:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install nesdit
run: go install github.com/sdkks/nesdit/cmd/nesdit@latest
- name: Preview change
run: |
nesdit helm/values.yaml -n \
--arg tag="${{ github.ref_name }}" \
--query '.image.tag = $tag'
- name: Check if already current
id: drift
run: |
nesdit helm/values.yaml --check \
--arg tag="${{ github.ref_name }}" \
--query '.image.tag = $tag'
echo "exit=$?" >> $GITHUB_OUTPUT
continue-on-error: true
- name: Apply and commit
if: steps.drift.outputs.exit == '2'
run: |
nesdit -i helm/values.yaml \
--arg tag="${{ github.ref_name }}" \
--query '.image.tag = $tag'
git config user.name "release-bot"
git config user.email "bot@example.com"
git add helm/values.yaml
git commit -m "chore: bump image tag to ${{ github.ref_name }}"
git push
Three steps: preview (log what changes), check (skip commit if already current), apply (one clean diff). The --arg injection avoids shell-quoting issues around the tag value.
Format Transcoding
nesdit can read one format and write another with --output-format:
# JSON → YAML
nesdit config.json --output-format yaml > config.yaml
# YAML → JSON
nesdit values.yaml --output-format json
# {"name":"alice","version":1}
# YAML → TOML
nesdit values.yaml --output-format toml
# name = "alice"
# version = 1
# TOML → JSON
nesdit config.toml --output-format json --query '.package'
When transcoding to TOML with multiple input documents (from JSONL or multi-doc YAML stdin), nesdit separates output documents with +++:
$ printf '{"a":1}\n{"b":2}\n' | nesdit --format jsonl --output-format toml
a = 1
+++
b = 2
Note: --output-format and -i are mutually exclusive — writing a different format back into the same file would silently corrupt it. Pipe to a new file instead.
How It Compares
| Feature | nesdit | jq | yq (mikefarah) | dasel |
|---|---|---|---|---|
| JSON | ✅ | ✅ | ✅ | ✅ |
| YAML | ✅ | ❌ | ✅ | ✅ |
| TOML write | ✅ | ❌ | ❌ | ✅ |
| Key order preserved | ✅ | ✅ | ✅ | ⚠️ |
| Comment preservation | ❌ | N/A | ❌ | ❌ |
YAML merge keys (<<:) |
✅ | N/A | ✅ | ✅ |
| Atomic in-place write | ✅ | ❌ (workaround) | ✅ | ✅ |
| Dry-run diff | ✅ | ❌ | ❌ | ❌ |
| Drift check (exit code) | ✅ | ❌ | ❌ | ❌ |
Multi-doc YAML + --where
|
✅ (stdin) | ❌ | ✅ | ✅ |
| jq query syntax | ✅ | ✅ | mostly | different |
| Single binary | ✅ | ✅ | ✅ | ✅ |
Where to use each tool:
- jq: JSON transformation and data extraction pipelines. Nothing beats it for raw JSON power.
- yq: YAML-heavy workflows, especially when you need XML, CSV, or properties format support, or full multi-doc file editing.
- dasel: Unified selector syntax if you prefer one query language across all formats.
-
nesdit: Targeted field edits in CI/CD pipelines, especially when you want
--checkdrift detection or--dry-runpreview before writing. Also the only option for TOML write support with a jq query syntax.
Safety Under Adversarial Inputs
nesdit ships with per-run resource caps by default:
| Limit | Default | Override |
|---|---|---|
| Input size | 10 MiB | --max-bytes 0 |
| Document nesting depth | 1000 | --max-depth 0 |
| YAML alias expansion | 100,000 nodes | --max-yaml-nodes 0 |
| Query file size | 1 MiB | --max-query-bytes 0 |
The YAML alias cap mitigates billion-laughs attacks — a crafted YAML file that expands exponentially during parsing. All limits are enforced before the query runs.
Where to Find It
- GitHub: https://github.com/sdkks/nesdit
- Docs: https://sdkks.github.io/nesdit
- Current release: v0.3.2
The project is in active development. If you hit an edge case — especially around TOML section formatting, comment handling, or a query behaviour that differs from jq — opening an issue with a minimal reproduction is the most useful contribution. Feature requests and PRs welcome.
Top comments (0)