Skip to content
Back to Blog
Tutorials

How to Ignore Timestamps and IDs in JSON Diff (Without Writing jq)

API regression diffs are 80% noise — timestamps, request IDs, UUIDs that mutate per-request. Use Extended JSON Pointer patterns to surface only meaningful changes.

12 min read

How to Ignore Timestamps and IDs in JSON Diff (Without Writing jq)

You add a new field to a REST endpoint. Your snapshot tests go red — not because the field changed, but because createdAt, requestId, and traceId mutated between the recorded snapshot and the re-run. You spend ten minutes confirming the diff is noise, silence the test, and move on. This repeats for every team member, on every build, every week.

The problem is not the test framework. The problem is that raw JSON diff treats every field equally. A rotating UUID is structurally identical to a field that carries semantic meaning. Without a way to say “ignore this path,” the diff surface includes every timestamp the server embeds, every auto-incremented ID, every per-request correlation token — none of which reflect whether the API contract changed.

This guide covers the tools and patterns that let you suppress that noise at the source: JSON Pointer path syntax, wildcard patterns for arrays, TypeScript helpers for key-based matching, Vitest snapshot integration, and a small CI script that exits non-zero only when the diff contains something that actually matters.

The “Noisy Diff” Problem — Why CI Fails on Irrelevant Changes

Consider a typical API regression workflow. You record the response from GET /orders/42:

{
  "id": "ord_9f8e7d6c",
  "status": "shipped",
  "createdAt": "2026-04-01T10:00:00Z",
  "updatedAt": "2026-04-01T12:34:56Z",
  "requestId": "req_abc123",
  "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}

You snapshot that response. Two weeks later, a colleague adds a shippingCarrier field and runs the suite. The diff shows six changed lines — four are timestamps and IDs that rotated server-side. The one meaningful change (the new field) is buried in the noise. Your colleague confirms nothing is broken, updates the snapshot, and the review goes ahead without anyone actually reading the functional diff.

Now multiply this across a microservices mesh with a hundred endpoints. Every build, CI diffs snapshot files that contain requestId, traceId, version, etag, x-request-start, serverTime. Reviewers become numb to snapshot diffs and approve them without reading. The regression signal collapses.

The fix is not to stop snapshot testing. It is to strip the noise before the diff runs.

What Counts as Noise: Timestamps, UUIDs, Auto-IDs, Hashes

Not every mutating field is noise. A status that changes from processing to shipped is signal. The question is which fields are structurally incapable of carrying regression information because they rotate by design.

CategoryExample field namesWhy it mutates
TimestampscreatedAt, updatedAt, deletedAt, lastSeen, expiresAtServer clock, time-of-request
Request correlationrequestId, traceId, spanId, correlationIdGenerated per-request
Auto-generated IDsid, uuid, nonce, idempotencyKeyUUID v4 / ULID / KSUID on every insert
ETags / cache tokensetag, eTag, cacheKey, version (when auto-incremented)Content hash or sequence
Build metadatabuildHash, deployId, serverVersion, hostnameSet at deploy time
Pagination cursorsnextCursor, prevCursor, pageTokenEncoded state, opaque

Fields in this table are candidates for the ignore list. Fields that encode actual domain state — status, amount, userId, items — should never be on it.

The goal is an ignore list that lives in code, gets reviewed in PRs, and is audited when security requirements change. Not a one-off jq pipeline someone wrote locally and forgot to commit.

JSON Pointer (RFC 6901) Syntax Primer

JSON Pointer, defined in RFC 6901, is the addressing scheme used by JSON Patch (RFC 6902) and most structured diff tools. It looks like a filesystem path and addresses a specific location in a JSON document.

/               → root document
/foo            → object key "foo"
/foo/bar        → nested key "bar" inside "foo"
/items/0        → first element of array "items"
/items/0/name   → key "name" inside first element

Two escape sequences handle special characters:

  • ~0 represents a literal ~
  • ~1 represents a literal /

So the key a/b is addressed as /a~1b, and the key a~b is addressed as /a~0b.

JSON Pointer differs from JSONPath (used in jq, OpenAPI) in a key way: it addresses exactly one location — no wildcards, no recursive descent, no filter expressions. RFC 6901 is strict and simple. The trade-off is that ignoring a field across all array elements requires either a tool that extends the spec with wildcard syntax, or iterating pointer paths programmatically.

When you see path fields in JSON Patch operations ([{"op":"replace","path":"/status","value":"shipped"}]), those are RFC 6901 pointers. Tools like fast-json-patch and rfc6902 use this notation natively.

Wildcard Patterns: /users/*/lastSeen for Arrays

RFC 6901 does not define wildcards. But most diff tools that are useful in practice extend the spec with a single-segment wildcard: * matches any one path segment without crossing /.

The rules for Extended JSON Pointer Pattern (as implemented in the Go Tools JSON Diff tool):

  • * matches any single path segment (array index or object key)
  • * does not cross / — it is not a recursive glob
  • To match a literal asterisk in a key name, escape it as \*
  • ** (double-star recursive descent) is not supported — use explicit paths

Examples:

/users/*/lastSeen        → matches /users/0/lastSeen, /users/1/lastSeen, etc.
/orders/*/updatedAt      → matches updatedAt in every element of orders array
/*/requestId             → matches requestId in every top-level object key
/data/items/*/id         → matches id in every element of a nested items array

The single-level constraint matters. /users/*/profile/*/tag is valid and matches exactly two levels of wildcard descent. It does not match /users/0/tag (too shallow) or /users/0/profile/0/meta/tag (too deep).

Practical ignore list for a typical REST API response:

/requestId
/traceId
/createdAt
/updatedAt
/data/*/createdAt
/data/*/updatedAt
/data/*/id
/pagination/nextCursor

This covers the common cases without touching any semantic fields.

Live Demo: Ignoring createdAt in a Stripe Webhook Payload

Stripe webhooks are a concrete example. Every event envelope contains created (Unix timestamp), id (event ID), request.id, and request.idempotency_key — all of which rotate per-invocation and are useless for regression testing.

Raw webhook payload:

{
  "id": "evt_1OqLmn2eZvKYlo2C9Pt1a4X7",
  "object": "event",
  "created": 1714819200,
  "type": "payment_intent.succeeded",
  "request": {
    "id": "req_Bc7dEfGhIj",
    "idempotency_key": null
  },
  "data": {
    "object": {
      "id": "pi_3OqLmn2eZvKYlo2C0Pt1a4X8",
      "amount": 2000,
      "currency": "usd",
      "status": "succeeded"
    }
  }
}

Ignore list for this shape:

/id
/created
/request/id
/request/idempotency_key
/data/object/id

After applying these ignores, the diff surface contains only type, data/object/amount, data/object/currency, and data/object/status — the fields that actually encode whether the payment behavior changed.

Paste both the before and after payloads into the JSON Diff tool, enter the ignore paths, and only the meaningful ops remain in the JSON Patch output. No jq pipeline. No pre-processing script.

Regex-Based Field Matching for Unknown Keys

JSON Pointer paths work when you know the structure in advance. They break down for two scenarios: dynamic object keys (maps with UUID keys, per-user data blobs) and “any field whose name ends in Id” patterns.

For these, you need regex-based key matching. Here is a TypeScript helper that walks a JSON tree and collects all pointer paths whose key name matches a pattern:

type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };

function findPathsByKeyPattern(
  value: JsonValue,
  pattern: RegExp,
  currentPath = ""
): string[] {
  const paths: string[] = [];

  if (Array.isArray(value)) {
    value.forEach((item, i) => {
      paths.push(...findPathsByKeyPattern(item, pattern, `${currentPath}/${i}`));
    });
  } else if (value !== null && typeof value === "object") {
    for (const [key, child] of Object.entries(value)) {
      const encodedKey = key.replace(/~/g, "~0").replace(/\//g, "~1");
      const childPath = `${currentPath}/${encodedKey}`;
      if (pattern.test(key)) {
        paths.push(childPath);
      }
      paths.push(...findPathsByKeyPattern(child, pattern, childPath));
    }
  }

  return paths;
}

// Usage: collect all paths whose key name ends in "Id" or "At"
const noisePattern = /(?:Id|At|Stamp|Hash|Token|Cursor)$/;
const ignorePaths = findPathsByKeyPattern(responseJson, noisePattern);
// → ["/requestId", "/data/0/createdAt", "/data/0/updatedAt", ...]

This gives you a dynamic ignore list derived from the actual response shape rather than a manually maintained set of strings. The caveat: if a legitimate field ends in Id (like userId or orderId), you need to either refine the pattern or subtract those paths from the result. Name patterns are not a substitute for explicit path lists — they are a complement for large APIs where maintaining every path manually is impractical.

JSON Patch Output with Ignored Paths Stripped

A JSON diff expressed as JSON Patch (RFC 6902) is a list of operations:

[
  { "op": "replace", "path": "/updatedAt", "value": "2026-05-04T09:00:00Z" },
  { "op": "replace", "path": "/status", "value": "shipped" },
  { "op": "add", "path": "/shippingCarrier", "value": "FedEx" }
]

Filtering ignored paths means removing operations whose path matches any entry in your ignore list (accounting for wildcards). The filtered output:

[
  { "op": "replace", "path": "/status", "value": "shipped" },
  { "op": "add", "path": "/shippingCarrier", "value": "FedEx" }
]

Two important caveats:

  1. Non-round-trip warning. A filtered patch cannot be applied to reconstruct the target document. If you apply only the non-noise operations to the source, you get a document that is missing the timestamp updates. Store the full patch for document reconstruction; use the filtered patch only for diffing and alerting.

  2. Order sensitivity. JSON Patch operations are ordered. Filtering ops from the middle of a patch that has move or copy operations referencing earlier positions can produce an invalid patch. If you need to apply the filtered patch, apply the full patch first, then re-diff the result against your expected state.

The JSON Diff tool outputs both the full patch and the filtered view, so you can inspect the raw diff before deciding which ops to suppress.

Snapshot Testing Recipes (Jest / Vitest)

Vitest’s snapshot system serializes values to strings and diffs them on re-run. The default serializer captures every field, including the noisy ones. The fix is a custom serializer that strips ignored paths before snapshotting.

// tests/setup/json-diff-serializer.ts
import { expect } from "vitest";

const IGNORE_PATHS = new Set([
  "/requestId",
  "/traceId",
  "/createdAt",
  "/updatedAt",
  "/data/id",
]);

function stripPaths(obj: unknown, path = ""): unknown {
  if (Array.isArray(obj)) {
    return obj.map((item, i) => stripPaths(item, `${path}/${i}`));
  }
  if (obj !== null && typeof obj === "object") {
    const result: Record<string, unknown> = {};
    for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
      const childPath = `${path}/${key.replace(/~/g, "~0").replace(/\//g, "~1")}`;
      if (!IGNORE_PATHS.has(childPath)) {
        result[key] = stripPaths(value, childPath);
      }
    }
    return result;
  }
  return obj;
}

expect.addSnapshotSerializer({
  test: (val) => val !== null && typeof val === "object" && !Array.isArray(val),
  print: (val, serialize) => serialize(stripPaths(val)),
});

Register it in vitest.config.ts:

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    setupFiles: ["./tests/setup/json-diff-serializer.ts"],
  },
});

Now your snapshots look like:

it("returns order with shipping carrier", async () => {
  const result = await getOrder("ord_42");
  expect(result).toMatchSnapshot(); // createdAt, requestId etc. are stripped
});

The snapshot file contains only stable fields. Rerunning the suite after a timestamp change produces zero diff. When the shippingCarrier field is added, the snapshot diff shows exactly that one change. You can also use the JSON Diff tool to manually verify what changed between two recorded responses before updating snapshots.

CI Integration: Failing Builds Only on Meaningful Diffs

The end goal: a CI step that downloads a reference response, hits the live endpoint, diffs the two, strips noise, and exits non-zero only if meaningful ops remain.

// scripts/api-regression-check.ts
import { applyPatch, compare } from "fast-json-patch";
import { readFileSync } from "fs";

const IGNORE_PATTERNS = [
  /^\/requestId$/,
  /^\/traceId$/,
  /^\/createdAt$/,
  /^\/updatedAt$/,
  /^\/data\/\d+\/createdAt$/,
  /^\/data\/\d+\/updatedAt$/,
  /^\/data\/\d+\/id$/,
];

function isMeaningfulOp(op: { path: string }): boolean {
  return !IGNORE_PATTERNS.some((re) => re.test(op.path));
}

async function main() {
  const reference = JSON.parse(readFileSync("reference.json", "utf-8"));
  const response = await fetch(process.env.API_URL + "/orders").then((r) => r.json());

  const ops = compare(reference, response);
  const meaningfulOps = ops.filter(isMeaningfulOp);

  if (meaningfulOps.length > 0) {
    console.error("Meaningful diff detected:");
    console.error(JSON.stringify(meaningfulOps, null, 2));
    process.exit(1);
  }

  console.log(`Clean diff (${ops.length} noise ops suppressed).`);
}

main().catch((e) => { console.error(e); process.exit(1); });

GitHub Actions step:

- name: API regression check
  env:
    API_URL: ${{ secrets.STAGING_API_URL }}
  run: npx tsx scripts/api-regression-check.ts

The compare function from fast-json-patch returns RFC 6902 operations. The filter runs over path strings using the same regex approach as the Vitest serializer. The script exits 0 if only noise changed, 1 if any meaningful field differs.

For wildcard matching (covering /data/*/updatedAt instead of /data/\d+\/updatedAt), replace the regex list with a glob-to-regex converter or use the ignore list format accepted by the JSON Diff tool and integrate it into your pipeline via its export.

When NOT to Ignore: Security-Sensitive Fields, Audit Trails

The ignore list is powerful. It is also dangerous when applied without discipline. Some fields look like noise but carry security-critical information.

Do not ignore:

  • createdAt in audit logs or financial records. If a transaction timestamp is wrong, that is a bug — not noise. Audit tables exist precisely to detect tampering via timestamp anomalies.
  • id fields in payment intents, invoices, or regulatory records. A rotating ID in a payment context might indicate duplicate charges or misrouted events.
  • version or etag in optimistic concurrency systems. If two processes both write with the same version, one will fail. The version field is not decorative.
  • traceId or spanId when debugging distributed system failures. These are noise in snapshot tests but signal in production incident analysis.
  • Any field your security team has flagged for monitoring — SIEM rules, compliance requirements, and data retention policies may depend on these fields being diff-visible.

The right model is treating the ignore list as a code-reviewed allowlist: a named constant or config file, checked into the repository, with a comment explaining why each path is on the list. Changes to the ignore list should require the same review as changes to production configuration.

// Explicit, auditable, requires PR review to change
export const SNAPSHOT_IGNORE_PATHS = [
  "/requestId",   // per-request UUID, no semantic content
  "/traceId",     // OpenTelemetry trace ID, rotates per request
  "/createdAt",   // server clock, stable in production fixtures
  // "/amount"    // NEVER ignore — financial field
] as const;

If you are unsure whether a field should be on the ignore list, err on the side of keeping it in the diff. A false positive (noise in the diff) costs a few seconds of review. A false negative (a regression missed because the path was ignored) costs your users.

Summary

Raw JSON diffs are noisy because server responses embed fields that rotate by design. JSON Pointer (RFC 6901) syntax gives you a precise addressing scheme. Wildcard patterns (/data/*/createdAt) extend that to arrays without writing custom traversal code. Regex-based key matching handles dynamic structures where paths are not known ahead of time. JSON Patch (RFC 6902) output with ignored paths stripped produces a clean signal for CI. A Vitest custom serializer applies the same logic to snapshot testing. And a disciplined, code-reviewed ignore list ensures the noise suppression does not accidentally swallow a real regression.

Try it hands-on in the JSON Diff tool — paste two JSON responses, enter your ignore paths, and see only the meaningful ops. Use the JSON Formatter to clean up minified responses before diffing.

Related Articles

View all articles