DEV Community

sdkks
sdkks

Posted on

nesdit: Targeted In-Place Edits for JSON, YAML, and TOML with jq-Style Queries

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
Enter fullscreen mode Exit fullscreen mode

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"'
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

YAML: Key Order Preserved, Merge Keys Work

# values.yaml
zebra: z-value
apple: a-value
mango: m-value
Enter fullscreen mode Exit fullscreen mode
$ nesdit values.yaml --query '.apple = "updated"'
zebra: z-value
apple: updated
mango: m-value
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
$ nesdit config.yaml --query '.web.image = "nginx:1.25"'
defaults:
  replicas: 1
  image: nginx
web:
  replicas: 3
  image: nginx:1.25
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
$ nesdit input.yaml --query '.replicas = 3'
name: myapp
replicas: 3
cpu: 100m
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
$ nesdit -i deploy.yaml --query '.replicas = 3 | .image = "nginx:1.25"'
Enter fullscreen mode Exit fullscreen mode
# deploy.yaml after
name: myapp
replicas: 3
image: nginx:1.25
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
$ nesdit config.toml --query '.version = "2.0"'
version = "2.0"
name = "project"
debug = false
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
$ nesdit Cargo.toml --query '.package.version = "0.2.0"'
package = {name = "my-crate", version = "0.2.0"}
dependencies = {tokio = "1.0"}
Enter fullscreen mode Exit fullscreen mode

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",
]
Enter fullscreen mode Exit fullscreen mode

--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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Command:

cat manifests.yaml | nesdit --format yaml \
  --where '.kind == "Deployment" and .metadata.name == "frontend"' \
  --query '.spec.template.spec.containers[0].image = "frontend:v2.0"'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Shell Variable Injection

--arg: string values

VERSION="v2.0.0"
nesdit -i values.yaml --arg version="$VERSION" --query '.image.tag = $version'
Enter fullscreen mode Exit fullscreen mode

--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
Enter fullscreen mode Exit fullscreen mode

--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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 --check drift detection or --dry-run preview 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

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)