Structure-aware configuration comparison CLI for semantic diff across JSON, YAML, TOML, and INI.
Project description
ConfigDiff
Structure-aware configuration comparison for humans and machines.
ConfigDiff compares structured config files semantically -- parsing JSON, YAML, TOML, and INI into normalised trees and performing recursive deep comparison. It detects added, removed, modified, and type-changed values at any depth, then produces clean human-readable output for terminal review or machine-readable JSON/YAML for CI/CD pipelines.
$ configdiff before.yaml after.yaml
Found 11 change(s): 2 added, 9 modified
~ bgp.neighbors[0].remote_as:
65001 → 65010
~ domain:
'lab.example.com' → 'prod.example.com'
~ interfaces.GigabitEthernet0/1.enabled:
True → False
+ interfaces.GigabitEthernet0/2: {'ip': '172.16.0.1', ...}
~ logging.level:
'info' → 'warning'
The Problem with diff
Traditional diff operates on lines of text. It has no understanding of structure, so it:
- Conflates formatting with real changes -- reindenting a YAML block produces dozens of "changes" that aren't
- Cannot detect type changes --
port: "8080"vsport: 8080looks identical todiff - Breaks on reordered lists -- moving a DNS server from position 0 to position 1 shows as two changes instead of zero
- Produces no machine-readable output -- downstream automation has to scrape unified-diff syntax
For anyone managing router configs, Kubernetes manifests, Terraform variables, or application settings at scale, line-based diff creates noise that obscures the signal.
ConfigDiff parses each file into a normalised tree, performs a recursive deep comparison, and reports only the values that actually changed -- with dot-notation paths like bgp.neighbors[0].remote_as, proper type awareness, and structured output that CI/CD pipelines can consume directly.
Key Features
| Feature | Description |
|---|---|
| Structure-aware diff | Recursive deep comparison of dicts, lists, and scalars -- not lines of text |
| 4 formats out of the box | JSON, YAML, TOML, INI with auto-detection from file extension |
| Change classification | Every change is categorised: added, removed, modified, type_changed |
| Dot-notation paths | Changes reported as bgp.neighbors[0].remote_as for precise identification |
| List order control | --ignore-order to treat [a, b] and [b, a] as equivalent |
| Multiple output formats | Human-readable text (with colour), machine-readable JSON, machine-readable YAML |
| CI/CD exit codes | 0 = no changes, 1 = changes detected, 2 = error |
| Plugin architecture | Extensible parsers and formatters -- add new formats without touching core code |
| Docker support | Slim, non-root container image for pipeline use |
| Minimal dependencies | Single runtime dependency (pyyaml); everything else is Python stdlib |
Quick Start
Install
pip install configdiff
Requires Python 3.11+.
Compare two configs
configdiff before.yaml after.yaml
Found 11 change(s): 2 added, 9 modified
~ bgp.neighbors[0].description:
'Peer ISP-A' → 'Peer ISP-A (migrated)'
~ bgp.neighbors[0].remote_as:
65001 → 65010
~ dns.servers[0]:
'8.8.8.8' → '1.1.1.1'
~ dns.servers[1]:
'8.8.4.4' → '8.8.8.8'
~ domain:
'lab.example.com' → 'prod.example.com'
~ interfaces.GigabitEthernet0/1.description:
'LAN segment' → 'LAN segment - maintenance'
~ interfaces.GigabitEthernet0/1.enabled:
True → False
+ interfaces.GigabitEthernet0/2: {'ip': '172.16.0.1', 'mask': '255.255.255.0', 'enabled': True, 'description': 'New DMZ segment'}
~ logging.level:
'info' → 'warning'
~ ntp.servers[0]:
'pool.ntp.org' → 'time.google.com'
+ ntp.servers[1]: 'pool.ntp.org'
Get machine-readable output
configdiff before.json after.json --format json
{
"summary": {
"modified": 6,
"added": 3
},
"total_changes": 9,
"changes": [
{
"path": "app.debug",
"type": "modified",
"old": true,
"new": false
},
{
"path": "app.version",
"type": "modified",
"old": "2.3.1",
"new": "2.4.0"
},
{
"path": "app.workers",
"type": "added",
"new": 4
},
{
"path": "database.pool_size",
"type": "modified",
"old": 5,
"new": 20
}
],
"metadata": {
"before": "examples/before.json",
"after": "examples/after.json",
"format": "json"
}
}
Write output to a file
configdiff before.yaml after.yaml --format json -o changes.json
Real-World Use Cases
Network Configuration Validation
Compare router configs before and after a change window to verify only intended changes were applied:
configdiff router-baseline.yaml router-current.yaml
ConfigDiff immediately shows that bgp.neighbors[0].remote_as changed from 65001 to 65010 and interfaces.GigabitEthernet0/1.enabled flipped to false -- no wading through whitespace noise.
Kubernetes / YAML Review
Compare staging and production manifests to catch configuration drift:
configdiff k8s/staging/deployment.yaml k8s/prod/deployment.yaml --format json
The JSON output feeds directly into review tooling or Slack notifications. Exit code 1 means drift exists; 0 means they match.
Config Drift Detection in CI
Gate deployments on configuration consistency:
# .github/workflows/config-check.yml
jobs:
config-drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install configdiff
- name: Detect drift
run: configdiff config/prod.yaml config/staging.yaml --format json -o drift.json
- name: Fail on drift
if: ${{ failure() || steps.detect-drift.outcome == 'failure' }}
run: |
echo "Configuration drift detected:"
cat drift.json
exit 1
Application Deployment Auditing
Compare the config deployed to production against the expected baseline:
configdiff expected.toml deployed.toml --format yaml -o audit.yaml
Machine-readable output flows into observability pipelines, ticketing systems, or compliance dashboards.
CLI Reference
usage: configdiff [-h] [-f {text,json,yaml}] [--ignore-order]
[-o FILE] [-v] [-V]
BEFORE AFTER
| Argument / Flag | Description |
|---|---|
BEFORE |
Path to the original (before) config file |
AFTER |
Path to the updated (after) config file |
-f, --format |
Output format: text (default), json, yaml |
--ignore-order |
Treat lists as unordered (ignore element position) |
-o, --output-file |
Write output to a file instead of stdout |
-v, --verbose |
Enable debug logging to stderr |
-V, --version |
Show version and exit |
Exit Codes
| Code | Meaning | CI interpretation |
|---|---|---|
0 |
No differences found | Configs match -- pass |
1 |
Differences detected | Drift or changes present -- review/fail |
2 |
Error | Bad input, missing file, parse failure |
Supported Formats
| Format | Extension(s) | Parser |
|---|---|---|
| JSON | .json |
json (stdlib) |
| YAML | .yaml, .yml |
pyyaml |
| TOML | .toml |
tomllib (stdlib, 3.11+) |
| INI | .ini, .cfg, .conf |
configparser (stdlib) |
Both files must use the same format. Format is auto-detected from the file extension.
Architecture
┌──────────────────┐
│ CLI (argparse) │
│ cli/app.py │
└────────┬─────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
┌────────────┐ ┌─────────────┐ ┌──────────┐
│ Parsers │ │ Diff Engine │ │ Output │
│ (plugin) │ │ (core) │ │ (plugin) │
└────────────┘ └─────────────┘ └──────────┘
BaseParser compare() BaseFormatter
Registry DiffEntry TextFormatter
JSON/YAML/ DiffResult JsonFormatter
TOML/INI ChangeType YamlFormatter
Data flow: The CLI resolves the file format via extension, dispatches to the appropriate parser to produce a normalised dict, passes both dicts to the diff engine which returns a DiffResult containing a list of DiffEntry dataclasses, and finally hands the result to the selected formatter for rendering.
Design Principles
- Plugin-extensible parsers -- Subclass
BaseParser, register withParserRegistry. New formats require zero changes to existing code. - Plugin-extensible formatters -- Subclass
BaseFormatter, add to the formatter map. SameDiffResultmodel regardless of input format. - Immutable data model --
DiffEntryis a frozen dataclass. The diff engine produces data; formatters only read it. - Separation of concerns -- Parsing, diffing, and formatting are fully independent modules. The CLI is a thin orchestration layer.
configdiff/
├── cli/
│ └── app.py # argparse entry point, exit codes
├── parsers/
│ ├── base.py # BaseParser ABC
│ ├── registry.py # ParserRegistry (format/ext → parser)
│ ├── json_parser.py
│ ├── yaml_parser.py
│ ├── toml_parser.py
│ └── ini_parser.py
├── diff_engine/
│ ├── models.py # DiffEntry, DiffResult, ChangeType
│ └── engine.py # Recursive deep-diff, compare()
├── output/
│ ├── base.py # BaseFormatter ABC
│ ├── text.py # Coloured terminal output
│ ├── json_output.py # Structured JSON
│ └── yaml_output.py # Structured YAML
└── utils/
├── format_detection.py # Extension-based format detection
└── logging.py # Logging configuration
Docker
Docker is provided as an optional distribution channel for CI/CD and containerised environments. The primary interface is pip install configdiff.
# Build
docker build -t configdiff .
# Compare files mounted from the host
docker run --rm -v "$PWD:/data" configdiff before.yaml after.yaml
# JSON output
docker run --rm -v "$PWD:/data" configdiff before.json after.json -f json
# Write output to a file on the host
docker run --rm -v "$PWD:/data" configdiff before.yaml after.yaml -o changes.json
The image uses python:3.12-slim, runs as a non-root user (UID 1000), and uses a multi-stage build to keep the final image minimal.
Extensibility
ConfigDiff is architected so that new capabilities can be added without modifying core code:
| Extension point | Mechanism | Future examples |
|---|---|---|
| New config formats | Subclass BaseParser, register with ParserRegistry |
XML, HCL/Terraform, Cisco IOS, JunOS |
| New output formats | Subclass BaseFormatter, add to formatter map |
HTML report, Markdown, Slack blocks |
| Diff plugins | Future DiffPlugin base class with pre_diff / post_diff hooks |
Network-aware semantics, risk scoring |
| Policy gates | Future --policy flag loading declarative rules |
"No debug: true in production" |
Adding a new parser is three steps:
from configdiff.parsers.base import BaseParser
from configdiff.parsers.registry import ParserRegistry
class XmlParser(BaseParser):
format_name = "xml"
extensions = [".xml"]
def parse(self, path):
... # parse and return a dict
ParserRegistry.register(XmlParser())
Development
git clone https://github.com/daniissac/configdiff.git
cd configdiff
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
Running Tests
# Full suite
pytest
# With coverage
pytest --cov=configdiff --cov-report=term-missing
# Single module
pytest tests/test_diff_engine.py -v
Project Structure
tests/
├── test_parsers.py # Parser unit tests (valid, malformed, edge cases)
├── test_diff_engine.py # Diff engine tests (add/remove/modify/type/nested/lists)
├── test_output.py # Formatter output verification
├── test_cli.py # End-to-end CLI integration tests
├── conftest.py # Shared fixtures
└── fixtures/ # Sample config files
examples/
├── before.yaml / after.yaml
└── before.json / after.json
Roadmap
ConfigDiff is designed for incremental extension. Planned future work:
- Network-aware diff plugins -- Semantic understanding of IP addresses, subnets, ASNs, VLAN ranges, interface naming
- Risk scoring engine -- Annotate changes with risk levels (e.g. disabling an interface = high, changing a description = low)
- CI/CD policy gates -- Declarative policy files that fail pipelines on violations (e.g. "no
debug: truein production") - GitHub Action -- First-class Action for PR-based config review workflows
- Additional formats -- XML, HCL/Terraform, Cisco IOS, JunOS
- Interactive TUI -- Terminal UI for navigating large diffs with folding and search
- AI-assisted explanations -- LLM-powered plain-language summaries of complex config changes
Contributing
Contributions are welcome. To get started:
- Fork the repository
- Create a feature branch (
git checkout -b feature/my-feature) - Write tests for your changes
- Ensure
pytestpasses with no regressions - Submit a pull request
Please keep PRs focused on a single change. For larger features, open an issue first to discuss the approach.
License
MIT License. See LICENSE for details.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file configdiff-0.1.0.tar.gz.
File metadata
- Download URL: configdiff-0.1.0.tar.gz
- Upload date:
- Size: 14.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
10f15c1d3216e47dc40862bf4069ece438fe085e71c5f108340c8e23c534ecf9
|
|
| MD5 |
ad3a72262477cc28161e1cedf6657d30
|
|
| BLAKE2b-256 |
6a519cd2c0894db58d32896f64c7d769985b94f6c91f38cf56e94e7099db267b
|
File details
Details for the file configdiff-0.1.0-py3-none-any.whl.
File metadata
- Download URL: configdiff-0.1.0-py3-none-any.whl
- Upload date:
- Size: 21.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6151ec9a042fd5b6b6d5d92e82f06b5ecbf6ca5f83d6abba86e2feabe468bfa6
|
|
| MD5 |
fb43e34796cff4cb1d9456ad14962a76
|
|
| BLAKE2b-256 |
5cce2d796cee4f04fc0efa28aba78722c5a1a7138c1517a99dd27b4567312b01
|