Debug API Changes — How to Compare API Responses to Find Breaking Changes

APIs change, and those changes break clients in subtle ways. A field gets renamed, a number becomes a string, a nullable field goes required. This guide covers tools and techniques to compare API responses, detect breaking changes, and debug unexpected behavior when an API behaves differently than expected — from quick curl diffs to automated CI/CD contract testing.

diff

the fundamental tool for comparing responses

JSON Schema

validate structure has not changed

HAR files

capture full request/response pairs for comparison

Semantic diff

detect type changes, not just text changes

1

Why API Responses Change (and How to Catch It)

The invisible breaking change

APIs change for many reasons: backend refactors, bug fixes, new features, and infrastructure migrations. Even "non-breaking" changes break clients: a field changing from int to float, a null becoming an empty string, or a date format changing from ISO 8601 to epoch milliseconds. The earlier you detect these, the cheaper they are to fix.

ItemChange TypeBreaking? Why?
Remove a fieldAlways breakingAny client reading that field crashes or gets undefined
Rename a field (user_id → userId)Always breakingOld field name returns undefined — same as removal
Change type (int → string)Usually breakingparseInt("123") works, but logic comparing === 123 breaks
Null → empty stringOften breakingif (field === null) checks miss empty string; if (!field) may work
Add required fieldBreaking for POST/PUTClients not sending new required field get 422/400
Add optional fieldNon-breakingClients ignore unknown fields — safe addition
Change date formatBreaking for parsersnew Date("2024-01-15T10:00:00Z") vs new Date(1705312800000)
Change status codesBreaking for status checks200 → 201 on create breaks if (!response.ok) still passes but if (status === 200) fails
2

Method 1 — curl + diff

bashCapture and Compare API Responses
# Step 1: Capture baseline response (before the change)
curl -s https://api.example.com/users/123 \
  -H "Authorization: Bearer TOKEN" \
  | python3 -m json.tool > baseline.json

# Step 2: After the API change, capture new response
curl -s https://api.example.com/users/123 \
  -H "Authorization: Bearer TOKEN" \
  | python3 -m json.tool > current.json

# Step 3: Compare with diff (shows line-level changes)
diff baseline.json current.json

# Better: use jq for normalized comparison (sorts keys, ignores formatting)
diff <(jq -S . baseline.json) <(jq -S . current.json)

# Colored diff output (easier to read)
diff --color=always <(jq -S . baseline.json) <(jq -S . current.json)

# Find only key changes (added/removed fields, not value changes):
diff <(jq -r '[paths | join(".")]' baseline.json | sort) \
     <(jq -r '[paths | join(".")]' current.json | sort)

# Compare specific nested field:
jq '.user.address' baseline.json > /tmp/old_addr.json
jq '.user.address' current.json > /tmp/new_addr.json
diff /tmp/old_addr.json /tmp/new_addr.json

# Batch compare all endpoints in a list:
for endpoint in /users /products /orders; do
  curl -s "https://api.example.com$endpoint" | jq -S . > "current_$(echo $endpoint | tr / _).json"
  diff "baseline_$(echo $endpoint | tr / _).json" "current_$(echo $endpoint | tr / _).json" \
    && echo "$endpoint: no changes" \
    || echo "$endpoint: CHANGED ⚠️"
done
3

Method 2 — JSON Schema Validation

pythonDetect Structural API Changes with JSON Schema
import jsonschema
import requests
import json
from typing import Any

# Define expected schema (generate from a known-good response with genson:
# pip install genson; python -c "from genson import SchemaBuilder; ...")
expected_schema = {
    "type": "object",
    "required": ["id", "name", "email", "created_at"],
    "properties": {
        "id": {"type": "integer"},
        "name": {"type": "string", "minLength": 1},
        "email": {"type": "string", "format": "email"},
        "created_at": {"type": "string", "pattern": r"^d{4}-d{2}-d{2}"},
        "role": {"type": "string", "enum": ["admin", "user", "viewer"]},
        "tags": {"type": "array", "items": {"type": "string"}},
        "metadata": {"type": ["object", "null"]},
    },
    "additionalProperties": False,  # Alert on new unknown fields
}

def check_api_contract(url: str, token: str) -> bool:
    response = requests.get(url, headers={"Authorization": f"Bearer {token}"})
    response.raise_for_status()
    data = response.json()

    try:
        jsonschema.validate(data, expected_schema)
        print(f"✅ {url} — matches expected schema")
        return True
    except jsonschema.ValidationError as e:
        print(f"❌ {url} — Schema violation:")
        print(f"   Error: {e.message}")
        print(f"   Path: {' → '.join(str(p) for p in e.path)}")
        print(f"   Got value: {e.instance!r}")
        return False
    except jsonschema.SchemaError as e:
        print(f"⚠️  Schema definition error: {e.message}")
        return False

# Run against multiple endpoints
endpoints = [
    "https://api.example.com/users/1",
    "https://api.example.com/users/2",
    "https://api.example.com/users/100",  # edge: near boundary
]

all_pass = all(check_api_contract(url, "mytoken") for url in endpoints)
print("\nAll checks passed!" if all_pass else "\n⚠️  Contract violations detected!")
4

Method 3 — Semantic Response Diffing

pythonDeep JSON Comparison — Type-aware
from typing import Any

def semantic_diff(old_data: Any, new_data: Any, path: str = "") -> list[str]:
    """Recursively compare JSON objects and report meaningful differences.
    Detects: type changes, added/removed fields, value changes."""
    issues = []

    # Type change is most critical — catches int→string, null→string, etc.
    if type(old_data) != type(new_data):
        issues.append(
            f"TYPE CHANGE at '{path}': "
            f"{type(old_data).__name__} → {type(new_data).__name__} "
            f"(was: {old_data!r}, now: {new_data!r})"
        )
        return issues  # Don't recurse — types differ fundamentally

    if isinstance(old_data, dict):
        old_keys, new_keys = set(old_data), set(new_data)

        # Removed keys (breaking!)
        for key in old_keys - new_keys:
            issues.append(f"REMOVED FIELD: '{path}.{key}' (was: {old_data[key]!r})")

        # Added keys (usually non-breaking, but track them)
        for key in new_keys - old_keys:
            issues.append(f"ADDED FIELD: '{path}.{key}' = {new_data[key]!r}")

        # Check common keys recursively
        for key in old_keys & new_keys:
            child_path = f"{path}.{key}" if path else key
            issues.extend(semantic_diff(old_data[key], new_data[key], child_path))

    elif isinstance(old_data, list):
        # Compare first elements as schema examples
        if old_data and new_data:
            issues.extend(semantic_diff(old_data[0], new_data[0], f"{path}[0]"))
        if bool(old_data) != bool(new_data):
            issues.append(f"EMPTINESS CHANGE at '{path}': was {'empty' if not old_data else 'non-empty'}")
    else:
        # Scalar value change — note but may be expected
        if old_data != new_data:
            issues.append(f"VALUE CHANGE at '{path}': {old_data!r} → {new_data!r}")

    return issues

# Usage: compare baseline with current
import requests, json

baseline = json.loads(open("baseline.json").read())
current = requests.get("https://api.example.com/users/1",
                       headers={"Authorization": "Bearer TOKEN"}).json()

issues = semantic_diff(baseline, current)
breaking = [i for i in issues if any(w in i for w in ["REMOVED", "TYPE CHANGE"])]
non_breaking = [i for i in issues if i not in breaking]

print(f"🔴 Breaking changes ({len(breaking)}):")
for issue in breaking: print(f"  {issue}")

print(f"\n🟡 Non-breaking changes ({len(non_breaking)}):")
for issue in non_breaking: print(f"  {issue}")
5

Method 4 — HAR File Comparison

HAR files capture the full context — not just the response

HAR (HTTP Archive) files include: request headers, response headers, timing, redirects, and the response body. Tools like Chrome DevTools, Insomnia, and Burp Suite can export HARs. Compare two HAR files to find exactly what changed in the full HTTP exchange — including headers like Content-Type, Cache-Control, and rate limit headers.

Export HAR from Chrome

DevTools → Network → right-click request → "Save all as HAR with content". Captures timing, all request/response headers, and full response bodies. Use for complex multi-request flows.

HARdiff (CLI tool)

npm install -g hardiff. Compare two HAR files: hardiff before.har after.har. Shows diffs in request/response pairs. Great for comparing browser-captured API calls.

Insomnia Request History

Insomnia stores history of all responses. Select two responses in the history panel → Compare — built-in diff view shows side-by-side request/response changes.

Postman Collections + Newman

Run a Postman collection against two environments (staging/production). Newman outputs pass/fail per test and captures response bodies for diff. Add pm.test() assertions for schema checks.

6

Automated API Change Detection in CI

yamlGitHub Actions — API Contract Tests
name: API Contract Tests
on:
  push:
    branches: [main, develop]
  pull_request:

jobs:
  api-contract:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.11'

      - name: Install dependencies
        run: pip install requests jsonschema

      - name: Run API schema validation
        run: python tests/validate_api_schema.py
        env:
          API_URL: https://staging.api.example.com
          API_TOKEN: ${{ secrets.TEST_TOKEN }}

      - name: Compare against baseline
        run: |
          # Fetch current response
          curl -s https://staging.api.example.com/users/1 \
            -H "Authorization: Bearer ${{ secrets.TEST_TOKEN }}" \
            | jq -S . > current.json

          # Diff against committed baseline
          diff tests/baselines/users_1.json current.json
          if [ $? -ne 0 ]; then
            echo "::error::API response changed — update baseline or fix regression"
            exit 1
          fi

      - name: OpenAPI contract test (dredd)
        run: |
          npm install -g dredd
          dredd openapi.yaml https://staging.api.example.com \
            --header "Authorization:Bearer ${{ secrets.TEST_TOKEN }}" \
            --reporter junit --output dredd-results.xml

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: api-test-results
          path: dredd-results.xml
7

Debugging Workflow Step by Step

1

Reproduce the issue with a minimal curl command

Isolate the breaking endpoint to a single curl -v command. Copy it from Chrome DevTools Network tab (right-click → Copy as cURL) to get exact headers and body. This eliminates client-side code as the source of the issue.

2

Capture baseline and current responses

Fetch from both the old (working) environment and the new (broken) environment. Save both with jq -S . for normalized formatting. If you don't have access to the old API, check your test fixtures, recorded VCR cassettes, or git history for saved responses.

3

Run semantic diff to classify changes

Use the semantic_diff() function or jq to compare key-by-key. Classify each change as breaking (removed/type-changed) or non-breaking (added). Focus on breaking changes first — they're the ones causing errors.

4

Validate against your schema

Run JSON Schema validation against the new response. This catches type changes (int→string) and missing required fields that raw diff might miss if the value happens to look similar.

5

Check HTTP headers and status codes

Status code changes (200→201, 200→204) break status-specific client code. Content-Type changes break parsers. Cache-Control changes can cause stale data issues. Use diff on the full curl -i output to catch these.

6

Update client code or request a revert

For breaking changes without deprecation notice: file a bug with the API team and request a revert or versioned endpoint. For intentional changes: update your client code, add migration tests, and update your baseline snapshots in CI.

Frequently Asked Questions

Related API Testing Guides

Continue with closely related troubleshooting guides and developer workflows.