A fast, structural YAML diff tool with built-in Kubernetes intelligence. One dependency, minimal attack surface, native CI annotations for GitHub, GitLab, and Gitea.
diffyml compares YAML files and shows meaningful, structured differences — not line-by-line text diffs.
📖 Full documentation: szhekpisov.github.io/diffyml
- Why diffyml?
- How It Compares
- Installation
- Quick Start
- Features
- Usage
- Library Usage
- Security & Code Quality
- Contributing
- Acknowledgments
- License
Fastest structural YAML diff tool at scale. On large (5K lines) and xlarge (50K lines) inputs diffyml is 1.5–1.9× faster than the nearest YAML-aware competitor. On small and medium files it ties within a few milliseconds — the residual overhead comes from capabilities the simpler tools lack (x509 certificate inspection, remote URL fetching, AI-powered summaries). See PERFORMANCE.md for methodology and results.
One dependency, zero surprises. A single module dependency (yaml.v3) and pure Go stdlib. Minimal attack surface, auditable in minutes.
Gets YAML right. Dotted keys, type preservation, mixed-type lists, nil values — concrete edge cases other tools get wrong. diffyml treats YAML semantics as first-class, not an afterthought.
| Feature | diffyml | dyff | plain diff |
|---|---|---|---|
| YAML-aware (structural diff) | Yes | Yes | No (line-based) |
| Kubernetes resource matching | By apiVersion + kind + name (or generateName) | By apiVersion + kind + name | No |
| Rename detection | Yes (content similarity — handles name changes) | Yes (identifier-based — name must stay the same) | No |
| API version migration | Yes (--ignore-api-version) |
No | No |
| CI annotation formats | 3 (GitHub, GitLab, Gitea) | 0 | 0 |
| Module dependencies | 1 (yaml.v3) | 23 | 0 |
| Directory comparison | Yes | No | Yes |
Git external diff (GIT_EXTERNAL_DIFF) |
Yes (auto-detect) | Manual (wrapper script) | N/A |
| Inline diff highlighting | Yes (word-level) | Yes (character-level) | No |
| Custom colors | Yes (hex, env vars) | No | No |
| Configuration file | Yes (.diffyml.yml) |
No | No |
| Performance (78 KB) | 19 ms | 120 ms (6.4x) | 6 ms |
| Performance (780 KB) | 129 ms | 1,146 ms (8.9x) | 45 ms |
Comparison based on dyff v1.11.3 and diffyml v1.5.23. See PERFORMANCE.md for full benchmark methodology and results against 5 competitors. Open an issue if anything is outdated.
brew tap szhekpisov/diffyml
brew install diffymlscoop bucket add diffyml https://github.com/szhekpisov/scoop-diffyml
scoop install diffymlAvailable in nixpkgs on the unstable channel. It landed after the 26.05 branch-off, so it isn't in the current stable release yet — it will be in the next one.
# Ephemeral shell
nix shell nixpkgs#diffyml
# Run without installing
nix run nixpkgs#diffyml -- old.yaml new.yaml
# Install into your profile
nix profile install nixpkgs#diffymlOn NixOS, add it to environment.systemPackages:
environment.systemPackages = with pkgs; [ diffyml ];Without flakes:
nix-env -iA nixpkgs.diffyml # or: nix-shell -p diffymlgo install github.com/szhekpisov/diffyml@latestMake sure $GOPATH/bin is in your PATH:
export PATH="$(go env GOPATH)/bin:$PATH"curl -fsSL https://szhekpisov.github.io/diffyml/install.sh | shDetects your OS and architecture, downloads the matching release archive, verifies its SHA256 against the signed checksums.txt, and installs to /usr/local/bin/diffyml. Customizable via env vars:
| Variable | Default | Notes |
|---|---|---|
DIFFYML_VERSION |
latest release | Pin a specific version, e.g. 1.6.1. Recommended in CI — avoids the unauthenticated GitHub API call (60 req/hr per IP) used to resolve the latest tag. |
INSTALL_DIR |
/usr/local/bin |
Falls back to sudo if the directory isn't writable. |
VERIFY |
sha256 |
Set cosign to verify the cosign signature on checksums.txt first (requires cosign in PATH), or none to skip verification. |
GITHUB_TOKEN |
unset | If set, used to authenticate the GitHub API call when resolving the latest version. Useful on shared CI egress IPs. |
# Pin a version, install into ~/bin, verify cosign signature too:
DIFFYML_VERSION=1.6.1 INSTALL_DIR="$HOME/bin" VERIFY=cosign \
sh -c "$(curl -fsSL https://szhekpisov.github.io/diffyml/install.sh)"Native packages for Debian/Ubuntu, RHEL/Fedora, and Alpine (amd64 and arm64) are attached to every release. All package archives are listed in the cosign-signed checksums.txt, so you can verify before installing — see Verifying Releases. The .apk uses --allow-untrusted because nfpm-built apks aren't GPG-signed; verify the SHA256 from checksums.txt instead.
# Debian / Ubuntu
curl -fLO "https://github.com/szhekpisov/diffyml/releases/download/v1.6.1/diffyml_1.6.1_linux_amd64.deb"
sudo dpkg -i diffyml_1.6.1_linux_amd64.deb
# RHEL / Fedora / openSUSE
curl -fLO "https://github.com/szhekpisov/diffyml/releases/download/v1.6.1/diffyml_1.6.1_linux_amd64.rpm"
sudo rpm -i diffyml_1.6.1_linux_amd64.rpm
# Alpine
curl -fLO "https://github.com/szhekpisov/diffyml/releases/download/v1.6.1/diffyml_1.6.1_linux_amd64.apk"
sudo apk add --allow-untrusted diffyml_1.6.1_linux_amd64.apkThe binary is installed to /usr/bin/diffyml.
If you'd rather not pipe a script to sh, the same archives are attached to every release for Linux, macOS, and Windows (amd64 and arm64). Linux/macOS ship as .tar.gz, Windows as .zip:
VERSION=1.6.1 # check the releases page for the latest
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
curl -fL "https://github.com/szhekpisov/diffyml/releases/download/v${VERSION}/diffyml_${VERSION}_${OS}_${ARCH}.tar.gz" \
| tar -xz
sudo mv diffyml /usr/local/bin/On Windows (PowerShell):
$Version = "1.6.1" # check the releases page for the latest
$Arch = if ($Env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "arm64" } else { "amd64" }
curl.exe -fLO "https://github.com/szhekpisov/diffyml/releases/download/v$Version/diffyml_${Version}_windows_$Arch.zip"
Expand-Archive "diffyml_${Version}_windows_$Arch.zip" -DestinationPath .
# move diffyml.exe somewhere on your PATHSee Verifying Releases below to check signatures and provenance before installing.
Multi-arch images (linux/amd64, linux/arm64) are published to GitHub Container Registry:
docker pull ghcr.io/szhekpisov/diffyml:latest
# Compare two files from the current directory
docker run --rm -v "$PWD:/work" -w /work ghcr.io/szhekpisov/diffyml:latest old.yaml new.yamlImages are built from a distroless base and run as a non-root user. Use :latest or pin to a specific version (e.g. :1.5.25).
git clone https://github.com/szhekpisov/diffyml.git
cd diffyml
go build -o diffymlPublished release artifacts are never modified or re-uploaded — each version is a one-time, append-only event. Every release includes:
- Checksums (
checksums.txt) — SHA256 hashes for all archives - Cosign signature (
checksums.txt.sigstore.json) — keyless Sigstore signature - SBOMs (
*.spdx.json) — SPDX Software Bill of Materials for each archive - SLSA provenance — Level 3 provenance attestation
Verification commands
Verify the checksums signature:
cosign verify-blob checksums.txt \
--bundle checksums.txt.sigstore.json \
--certificate-identity-regexp 'https://github.com/szhekpisov/diffyml/' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com'
# Linux
sha256sum --check checksums.txt --ignore-missing
# macOS
shasum -a 256 --check checksums.txt --ignore-missingVerify SLSA provenance:
gh attestation verify diffyml_<VERSION>_linux_amd64.tar.gz \
--repo szhekpisov/diffymlVerify the container image signature:
cosign verify \
--registry-referrers-mode=oci-1-1 \
--certificate-identity-regexp 'https://github.com/szhekpisov/diffyml/' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
ghcr.io/szhekpisov/diffyml:<VERSION># Compare two local files
diffyml old.yaml new.yaml
# Compare local file against a remote URL
diffyml local.yaml https://example.com/remote.yaml
# Use in CI — exit code 1 when differences found
diffyml -s deployment-old.yaml deployment-new.yaml
# Use as kubectl external diff provider:
export KUBECTL_EXTERNAL_DIFF="diffyml --omit-header --set-exit-code"- 7 output formats — detailed, compact, brief, GitHub, GitLab, Gitea, JSON
- Path filtering — include/exclude paths with exact match or regex
- Remote files — compare directly from HTTP/HTTPS URLs
- Certificate inspection — inspects and compares embedded x509 certificates
- Chroot navigation — focus comparison on a specific YAML subtree
- Git integration — use as
GIT_EXTERNAL_DIFFor via.gitattributesfor YAML-only scoping - Inline diff highlighting — highlights only the changed parts within scalar values (version tags, IPs, ports) for quick scanning
- Custom colors — configurable color palette for accessibility (colorblind-friendly)
- Configuration file — project-level defaults via
.diffyml.yml(all flags supported) - Sensitive value masking — opt-in redaction of Kubernetes Secret
data/stringDataand arbitrary paths; applies to every output format - ⭐ AI-powered summaries ⭐ — natural language summaries of changes via Anthropic API
diffyml [flags] <from> <to>| Format | Flag | Use case |
|---|---|---|
| detailed | -o detailed (default) |
Human review — full context |
| compact | -o compact |
Quick scan of changes |
| brief | -o brief |
Summary only |
| github | -o github |
GitHub Actions annotations |
| gitlab | -o gitlab |
GitLab CI annotations |
| gitea | -o gitea |
Gitea CI annotations |
| json | -o json |
Machine-readable — piping, scripting, CI |
Use -s / --set-exit-code to set the exit code based on differences:
| Exit code | Meaning |
|---|---|
0 |
No differences (or success without -s) |
1 |
Differences detected (only with -s) |
255 |
Error occurred |
diffyml -s before.yaml after.yaml || echo "Config drift detected"GitHub Actions — use the diffyml-action composite action (no manual binary install):
- uses: szhekpisov/diffyml-action@v1
with:
from: old.yaml
to: new.yamlTo inspect drift without failing the job, read the has-differences output:
- uses: szhekpisov/diffyml-action@v1
id: diff
with:
from: old.yaml
to: new.yaml
fail-on-diff: 'false'
output: github
- if: steps.diff.outputs.has-differences == 'true'
run: echo "Configuration drift detected"See the action repo for the full list of inputs and outputs.
Resources are auto-detected and matched by apiVersion + kind + metadata.name (or metadata.generateName), so diffs stay meaningful even when document order changes.
Rename detection — when resources can't be matched by identifier (e.g., kustomize configMapGenerator hash-suffix changes like app-config-abc123 → app-config-def456), diffyml pairs unmatched documents by content similarity (60% threshold) and shows field-level diffs instead of bulk add/remove. Disable with --detect-renames=false.
API migration — --ignore-api-version drops apiVersion from the matching key, so an upgrade from apps/v1beta1 to apps/v1 shows field-level diffs instead of a remove + add.
Opt out — --detect-kubernetes=false disables K8s-aware matching entirely and compares documents by position.
# Compare two Kubernetes manifests
diffyml manifests-v1.yaml manifests-v2.yaml
# API migration — match by kind + name only
diffyml --ignore-api-version manifests-v1.yaml manifests-v2.yaml
# Disable Kubernetes detection
diffyml --detect-kubernetes=false file1.yaml file2.yamldiffyml accepts two directories as positional arguments. It recursively discovers all regular files in each directory (regardless of extension), matches them by relative path, and shows aggregated differences. Files that cannot be parsed as YAML are silently skipped.
This makes diffyml a drop-in KUBECTL_EXTERNAL_DIFF provider — kubectl passes two temporary directories containing extensionless temp files (e.g. apps.v1.Deployment.default.nginx), and diffyml discovers them automatically:
export KUBECTL_EXTERNAL_DIFF="diffyml --omit-header --set-exit-code"
kubectl diff -f manifests/diffyml can be used as a git external diff program. Git passes 7-9 positional arguments which diffyml auto-detects — non-YAML files are skipped with a warning.
# One-off: structural diff for YAML changes in the working tree
GIT_EXTERNAL_DIFF=diffyml git diff
# With flags (e.g. compact output)
GIT_EXTERNAL_DIFF='diffyml -o compact' git diffFor permanent setup, use .gitattributes — git's built-in diff handles all other file types normally:
*.yaml diff=diffyml
*.yml diff=diffymlgit config diff.diffyml.command diffymlColor and truecolor are auto-forced (git's pager makes stdout a pipe). Use --color never to disable. --set-exit-code is silently ignored — git aborts external diff programs that exit non-zero. Parse errors are non-fatal: a warning is printed and git continues to the next file.
Generate a natural language summary of changes using the Anthropic API:
export ANTHROPIC_API_KEY="sk-ant-..."
# Append AI summary after the diff output
diffyml --summary old.yaml new.yaml
# Use with brief format — replaces brief output with AI summary
diffyml --summary -o brief old.yaml new.yaml
# Use a different model
diffyml --summary --summary-model claude-sonnet-4-5-20250514 old.yaml new.yamlThe summary is appended after the standard diff output. If the API call fails, a warning is printed to stderr and the diff output is preserved. The exit code is never affected by summary success or failure.
Masking is opt-in. When enabled, diffyml replaces the value of any matching diff with *** before rendering — applies to every output format (including json, json-patch, and the --summary prompt). Diffs still show that a value changed, just not what the value was.
# Auto-mask data/stringData of Kubernetes Secret resources
diffyml --mask-secrets secrets-old.yaml secrets-new.yaml
# Mask additional paths (e.g., a ConfigMap field that holds an API key)
diffyml --mask-path 'data.api_key' --mask-path 'data.db_url' old.yaml new.yaml
# Regex variant — mask any field whose path matches
diffyml --mask-path-regexp '(?i)password|token|secret' old.yaml new.yaml
# Custom placeholder
diffyml --mask-secrets --mask-placeholder '<REDACTED>' old.yaml new.yamlMask paths use the same dot-notation as --filter / --exclude (prefix match honored). The leading document index for multi-document files ([0], [1]) is automatically stripped before matching.
Configure defaults via .diffyml.yml so masking is always on for this repo:
mask-secrets: true
mask-path:
- "data.api_key"
mask-path-regexp:
- "(?i)password"
mask-placeholder: "***"# Show only changes under a specific path
diffyml --filter spec.replicas old.yaml new.yaml
# Exclude noisy paths
diffyml --exclude metadata.annotations old.yaml new.yaml
# Keys containing dots use bracket syntax
diffyml --exclude 'metadata.annotations[argocd.argoproj.io/tracking-id]' old.yaml new.yaml
# Regex filtering
diffyml --filter-regexp 'spec\.containers\[.*\]\.image' old.yaml new.yaml-u, --unchanged inverts the report: instead of the differences, it lists the keys/values that are equal between the two files. Equal subtrees collapse to a single entry at the highest fully-equal node, and it honors every output format, --filter/--exclude, and masking.
Filters can target descendants of a collapsed equal subtree using either numeric list indices (containers.0.image) or list identifiers (containers.app.image). The collapsed subtree remains atomic in the output: matching or excluding any descendant keeps or removes the whole entry, respectively.
# Find values that already match the chart defaults (candidates to drop)
diffyml --unchanged values.yaml chart-defaults.yamlComparison is at key/value granularity — map keys, list items, and whole scalars. List items are matched the same way as in a normal diff: by identifier (name/id), order-independently under --ignore-order-changes or for heterogeneous lists, and otherwise positionally. A multi-line (block) string is compared as a single scalar: if any line inside it differs, the whole value is "changed" and none of its lines are reported as unchanged. Inverse mode does not line-diff inside strings (unlike the normal diff, which shows a line-by-line diff for modified multi-line strings).
--neat excludes well-known noise paths injected by the Kubernetes API server, kubectl, Helm, ArgoCD, and Flux — metadata.managedFields, metadata.resourceVersion, the entire status subtree, meta.helm.sh/release-name, helm.sh/chart, argocd.argoproj.io/tracking-id, kustomize.toolkit.fluxcd.io/*, and similar paths. The full strip list lives in doc/neat.md.
# Compare a kube-apiserver-rendered manifest against your source-of-truth
# (using process substitution; diffyml reads files, not stdin)
diffyml --neat <(kubectl get deployment nginx -o yaml) source.yaml
# In a kubectl/helm/argocd diff workflow
KUBECTL_EXTERNAL_DIFF="diffyml --neat" kubectl diff -f deployment.yaml
# See what neat actually filtered
diffyml --neat --neat-explain old.yaml new.yamlPer-layer opt-outs let you keep one bundle while stripping the rest:
# Show Helm chart-version diffs but still strip ArgoCD/Flux/managedFields
diffyml --neat --no-neat-helm old.yaml new.yaml--neat-strip-path extends the bundle without rebuilding (requires --neat):
diffyml --neat --neat-strip-path '^metadata\.annotations\[my\.org/.*\]$' old.yaml new.yaml--neat deliberately preserves spec.template.metadata.annotations[kubectl.kubernetes.io/restartedAt] (intentional rollout marker), spec.replicas, data/stringData (use --mask-secrets for those), and user-defined labels/annotations outside the canonical noise prefixes.
diffyml loads project-level defaults from .diffyml.yml (or .diffyml.yaml) in the current directory. CLI flags override config file values. Use --config to specify a custom path.
# .diffyml.yml
output: compact
ignore-order-changes: true
detect-kubernetes: false
filter:
- "spec.containers"
exclude:
- "status"All CLI flags are supported as config keys (kebab-case, matching the long flag name). Unknown keys are rejected to catch typos. See .diffyml.yml.example for a complete reference with all keys and defaults.
Diff colors can be customized for accessibility (e.g., colorblind-friendly palettes). Five color roles are configurable: added, removed, modified, context, and doc-name.
Colors are specified as hex (#rrggbb, #rgb).
Via config file:
# .diffyml.yml
colors:
added: "#6aa3a5"
removed: "#702d06"Via environment variables (override config file):
export DIFFYML_COLOR_ADDED="#6aa3a5"
export DIFFYML_COLOR_REMOVED="#702d06"
diffyml old.yaml new.yamlAvailable environment variables: DIFFYML_COLOR_ADDED, DIFFYML_COLOR_REMOVED, DIFFYML_COLOR_MODIFIED, DIFFYML_COLOR_CONTEXT, DIFFYML_COLOR_DOC_NAME.
Complete flag reference
Output
| Flag | Description |
|---|---|
-o, --output <style> |
Output style: detailed, compact, brief, github, gitlab, gitea, json (default detailed) |
-c, --color <mode> |
Color usage: always, never, auto (default auto) |
-t, --truecolor <mode> |
True color (24-bit): always, never, auto (default auto) |
| Comparison |
| Flag | Description |
|---|---|
-i, --ignore-order-changes |
Ignore order changes in lists |
--ignore-whitespace-changes |
Ignore leading/trailing whitespace differences |
--format-strings |
Canonicalize embedded JSON strings before comparison (suppresses formatting-only diffs) |
-v, --ignore-value-changes |
Show only structural changes, exclude value changes |
--detect-kubernetes |
Detect and match Kubernetes resources (default true) |
--detect-renames |
Detect renamed/moved Kubernetes resources by content similarity (default true) |
--ignore-api-version |
Ignore apiVersion when matching Kubernetes resources |
-x, --no-cert-inspection |
Disable x509 certificate inspection |
--swap |
Swap from/to files |
-u, --unchanged |
Inverse diff: report keys/values equal between both files instead of differences |
Filtering
| Flag | Description |
|---|---|
--filter <path> |
Include only differences at specified paths (repeatable) |
--exclude <path> |
Exclude differences at specified paths (repeatable) |
--filter-regexp <pattern> |
Filter using regular expressions (repeatable) |
--exclude-regexp <pattern> |
Exclude using regular expressions (repeatable) |
--additional-identifier <field> |
Additional field for list item identification |
Sensitive Value Masking
| Flag | Description |
|---|---|
--mask-secrets |
Auto-mask data / stringData of Kubernetes Secret resources |
--mask-path <path> |
Additional path to mask, dot-notation with prefix match (repeatable) |
--mask-path-regexp <pattern> |
Additional path to mask, regex (repeatable) |
--mask-placeholder <string> |
Placeholder for masked values (default ***) |
Display
| Flag | Description |
|---|---|
-b, --omit-header |
Omit summary header |
-g, --use-go-patch-style |
Use Go-Patch style paths |
--multi-line-context-lines <int> |
Context lines for multi-line strings (default 4) |
Chroot
| Flag | Description |
|---|---|
--chroot <path> |
Change root level for both files |
--chroot-of-from <path> |
Change root level for the from file only |
--chroot-of-to <path> |
Change root level for the to file only |
--chroot-list-to-documents |
Treat chroot list as separate documents |
AI Summary
| Flag | Description |
|---|---|
-S, --summary |
Generate AI-powered natural language summary (requires ANTHROPIC_API_KEY) |
--summary-model <model> |
Model for AI summary (default claude-haiku-4-5-20251001) |
Other
| Flag | Description |
|---|---|
--config <path> |
Path to config file (default .diffyml.yml in current directory) |
-s, --set-exit-code |
Exit code 1 if differences found |
-h, --help |
Show help |
-V, --version |
Show version information |
diffyml can be used as a Go library for programmatic YAML comparison.
import "github.com/szhekpisov/diffyml/pkg/diffyml"
// Compare two YAML documents
from, _ := diffyml.LoadContent("old.yaml")
to, _ := diffyml.LoadContent("new.yaml")
diffs, err := diffyml.Compare(from, to, &diffyml.Options{
DetectKubernetes: true,
})
if err != nil {
log.Fatal(err)
}
// Format the differences
formatter, _ := diffyml.FormatterByName("compact")
fmt.Print(formatter.Format(diffs, diffyml.DefaultFormatOptions()))See the package documentation for the full API reference.
Supply chain. Releases are signed with cosign (keyless Sigstore), ship SPDX SBOMs for every artifact, and carry SLSA Level 3 build provenance. Published tags are immutable. See Verifying Releases for verification commands. The repo is tracked by OpenSSF Scorecard (badge above).
Continuous checks. Every push and PR is scanned by:
- govulncheck — known vulnerability detection (runs on
mainweekly as well) - zizmor — GitHub Actions workflow security scanning
- golangci-lint running: errcheck, gocritic, gosec, govet (with shadow detection), ineffassign, misspell, staticcheck (all checks except style conventions)
Test quality. 1,500+ tests (unit, e2e, fuzz, property-based), 99%+ aggregate code coverage across the core and CLI packages, mutation testing gated per-PR (no LIVED mutant on changed lines) with an 85% post-merge efficacy floor. CI enforces a 99% aggregate coverage floor.
Reporting vulnerabilities. See SECURITY.md — preferred path is a private GitHub Security Advisory.
Contributions welcome! Open an issue for bugs or feature requests.
Development setup
Prerequisites: Go 1.26.5+, pre-commit
git clone https://github.com/szhekpisov/diffyml.git
cd diffyml
pre-commit installPre-commit hooks run automatically on every commit:
| Hook | What it checks |
|---|---|
gofmt |
Code formatting |
go vet |
Static analysis |
check-coverage |
Aggregate core + CLI coverage threshold (99% overall) |
govulncheck |
Known vulnerabilities |
golangci-lint |
7 linters (errcheck, gocritic, gosec, govet, ineffassign, misspell, staticcheck) |
Useful Make targets:
make test # run all tests
make ci # full CI pipeline locally (fmt + vet + test + coverage + security)
make bench # run benchmarks
make bench-compare # compare against alternative tools (see doc/PERFORMANCE.md)
make coverage # generate HTML coverage report
make mutation # run mutation testing (requires gomutants)CI pipelines (run on every push and PR):
- Tests — unit tests + coverage thresholds
- Security & Static Analysis — govulncheck + golangci-lint (also runs weekly)
- Benchmark — performance regression tracking
- Mutation Testing — test quality validation via gomutants
This project is heavily inspired by dyff, and it wouldn't be possible without the hard work of the maintainers and contributors of that project.
MIT.
If you find this project useful, please consider giving it a ⭐ — it helps others discover it.

