Module 3: Exporting, Normalizing, and Structuring the Repository
4. Normalizing the JSON for clean diffs
Description
By the end of this lesson you will be able to take the raw JSON n8n's CLI exports and clean it up so Git shows you readable diffs: only what really changed, with none of the noise from fields that change on their own. You'll know what a volatile field is and which ones show up in an n8n workflow, why the JSON's key order causes false diffs, and you're going to write a small normalization script —with jq or with Node— that removes the noise and orders the keys stably. That script is one of the pieces lesson 7 is going to automate.
This matters because an unreadable diff kills the whole reason for versioning. The point of having order-triage in Git is being able to open a git diff and understand, in ten seconds, what changed between yesterday and today. If every save clutters the diff with forty lines that changed on their own, reviewing becomes impossible, nobody reads the diffs, and version control degrades to "a folder with backups." Worse still: when two people work on the same workflow, an unordered JSON multiplies merge conflicts. Normalizing turns the JSON from "technically versioned" into "actually reviewable."
Connection to the module: in lesson 2 you exported the JSON; in lesson 3 you got the secrets out of the way. Now that JSON, clean of secrets, is still noisy, and this lesson polishes it. It's the last step of "getting the material clean" before moving on to "giving it shape" in lesson 5 (structure) and lesson 6 (documentation). And the script you write here joins lesson 2's export command to form, in lesson 7, a single export.sh that does everything in one pass. Pay attention to the script's shape: you're going to reuse it.
Why raw JSON gives dirty diffs
Let's go back to the order-triage JSON you exported. Save it, change one tiny thing in the editor —move a node two centimeters, or nothing at all: just open and save— export again, and compare the two with git diff. What you're going to see is baffling the first time: Git flags several lines as changed, even though you didn't touch the logic.
So what changed? Fields n8n modifies on its own every time you save or export. We call these fields volatile: they change on their own, unrelated to what you did, like a temperature that rises and falls even though nobody touches the thermostat. These are the main ones in an n8n workflow:
| Field | What it is | Why it's noise |
|---|---|---|
pinData | Test data you "pinned" to a node while editing | Changes depending on what you test with; not logic |
versionId | A saved version's identifier | n8n regenerates it on every save |
meta.instanceId | Identifies your n8n server | Belongs to your machine, not the workflow; different on every instance |
id | The workflow's identifier in the database | Instance-specific; another instance gives it a different one |
active | Whether the workflow is turned on or not | It's state, not logic; and depends on the environment |
triggerCount | An internal trigger counter | Changes with use, not with editing |
createdAt, updatedAt | Creation and last-modification timestamps | Change with every save, by definition |
Notice the pattern: none of these fields describes the workflow's logic. They're state, instance, or moment metadata. When you review a diff, what you want to see is "the If node's condition changed" or "an HTTP node got added," not "versionId went from e7f8... to a1b2...." Volatile fields are pure smoke between you and the signal.
There's a second culprit, more subtle, and it's the one causing the most treacherous diffs.
Key order: the phantom diff
JSON, by design, doesn't guarantee the order of keys inside an object. These two fragments are identical to a machine:
{ "name": "order-triage", "active": false }
{ "active": false, "name": "order-triage" }
They represent exactly the same data. But for Git, which compares text line by line, they're different: the lines are in a different order. If one export puts name before active and the next flips them, Git is going to flag both lines as changed, even though the content is the same.
This really happens: different n8n versions, or even the same n8n at different moments, can serialize the keys in different orders. The result is a diff screaming "everything changed!" when nothing did. It's a phantom diff: noise that looks like signal.
The fix for both problems —volatile fields and order— is normalization.
What normalizing is
Normalizing a JSON means transforming it into a canonical form: always the same structure, always the same order, without the fields that add nothing. The word comes from "norm": you're imposing a fixed norm on the file, so two exports of the same workflow produce exactly the same text, byte for byte.
It's two operations, and it's worth keeping them separate in your head:
- Removing volatile fields. You delete
pinData,versionId,meta.instanceId, and company. What's left is only the logic. - Ordering the keys stably. You rewrite the JSON with the keys always in the same order (alphabetical, typically). That way the phantom diff disappears: if the logic didn't change, the text is identical.
Think of it as organizing a toolbox before putting it away. If every time you close the box the tools end up in a different order, you never know if something's missing: everything looks different. If you always store them in the same spot —the wrench on the left, the screwdriver on the right— one glance instantly tells you if something changed. Normalizing is storing the JSON always in the same order, so the change jumps out at you.
The result: after normalizing, git diff shows only what you really touched. You move a node two centimeters without changing its configuration, and the diff is empty. You change a node's condition, and the diff shows exactly that line. That's what makes a workflow reviewable.
Tool 1: jq
The most direct way to normalize JSON in the terminal is jq.
jq is a command-line program for processing JSON. Its name is pronounced "jay-cue." It reads JSON on one side, applies a transformation you describe, and writes the transformed JSON on the other. It's to JSON what a calculator is to numbers: you give it an expression and it gives you the result. It doesn't come installed by default on every system; you install it with your package manager (brew install jq on macOS, apt install jq on Debian/Ubuntu). If you don't have it, in a moment you'll see the Node alternative.
Normalization's two operations translate to jq like this:
Ordering the keys: the -S flag (or its long form --sort-keys) rewrites the JSON with every key sorted alphabetically, recursively —including keys nested inside nodes. A single flag solves the entire phantom diff.
Removing fields: the del(...) function deletes the keys you tell it to. It's written with a dot before each field, and several are separated by commas:
del(.pinData, .versionId, .active, .triggerCount, .meta.instanceId)
Read it as an instruction: "delete pinData, versionId, active, triggerCount, and, inside meta, the instanceId key." The dot means "the key at the object's root"; .meta.instanceId goes one level down to delete only that nested key without touching the rest of meta.
Anatomy of the jq normalization command
Putting the two operations together:
jq -S 'del(.pinData, .versionId, .active, .triggerCount, .meta.instanceId)' order-triage.json
Piece by piece:
jq— the program.-S— sorts the keys alphabetically (solves the order).'del(...)'— the transformation, in single quotes so the terminal doesn't interpret the dots or parentheses. Deletes the volatile fields.order-triage.json— the input file jq reads.
What to expect: jq prints order-triage's JSON to the terminal without those five fields and with the keys sorted. Watch out: it prints it, it doesn't modify the file. For the change to be saved, you have to capture that output into a file, and there's an important trap right there.
The "write to the same file" trap
The temptation is doing this:
# ⚠️ WRONG: this empties the file
jq -S 'del(.pinData)' order-triage.json > order-triage.json
Don't do this. The problem is order of operations. The > symbol redirects the output to order-triage.json, and the terminal opens and empties that file before jq even starts reading it. Result: jq tries to read a file that's already empty, and you end up with a blank order-triage.json. Your workflow disappears.
The correct way is writing to a temporary file and then replacing:
jq -S 'del(.pinData, .versionId, .active, .triggerCount, .meta.instanceId)' order-triage.json > order-triage.tmp && mv order-triage.tmp order-triage.json
Let's break it down:
... > order-triage.tmp— jq writes the result to a new, temporary file. The original stays intact while jq reads.&&— "and if the previous one went fine, then." Chains the two commands with a condition: only continues if jq finished without error. If jq fails, themvdoesn't run and your original is saved.mv order-triage.tmp order-triage.json— replaces the original with the already-normalized temp file.mvmeans "move/rename."
This pattern —write to a temp file, and with && replace only if it went well— is a reflex worth adopting for any tool that "processes a file in place." It isn't exclusive to jq.
Worked example: normalizing order-triage
Let's see it end to end, with the before and after.
Before. This is a trimmed excerpt of the freshly exported order-triage.json, with the volatile fields flagged:
{
"active": true,
"id": "aBcD1234EfGh5678",
"name": "order-triage",
"nodes": [ /* ... the real logic ... */ ],
"connections": { /* ... */ },
"pinData": {
"Webhook": [ { "json": { "order_id": "ORD-2041", "customer_name": "Luna Coffee" } } ]
},
"triggerCount": 3,
"versionId": "e7f8a9b0-1111-2222-3333-444455556666",
"meta": { "instanceId": "9c8b7a6d5e4f3a2b1c0d..." }
}
The command:
jq -S 'del(.pinData, .versionId, .active, .triggerCount, .meta.instanceId)' order-triage.json > order-triage.tmp && mv order-triage.tmp order-triage.json
After. The file ends up like this —no volatile fields, keys sorted alphabetically:
{
"connections": { /* ... */ },
"id": "aBcD1234EfGh5678",
"meta": {},
"name": "order-triage",
"nodes": [ /* ... the real logic ... */ ]
}
Notice three things. active, pinData, triggerCount, and versionId are gone. meta ended up as an empty object {} because we removed its only key, instanceId —you can optionally delete meta entirely with del(.meta) if you prefer, it's a matter of taste. And the keys ended up in alphabetical order: connections, id, meta, name, nodes. The logic —nodes and connections— is intact. Only the smoke is gone.
The real test: now open order-triage in the editor, save it without changing anything, export it again, and normalize it with the same command. Compare with git diff. What to expect: the diff is empty. The versionId did change in the raw export, yes, but since normalization removes it, the normalized file is identical. That's exactly what we were after: saving without changing logic doesn't clutter the repo.
An honest decision: what about id?
You noticed I left the workflow's id in the example. It's a decision with a nuance worth understanding, because there's no single correct answer.
The id is instance-specific: on Cumbre's dev, order-triage has one id; on prod, it might have another. That makes it half volatile. You could delete it with del(.id) so the file is totally independent of the instance. The cost: on reimport, n8n uses the id to know whether a workflow already exists and needs updating, or is new and needs creating. Without an id, you risk the import creating a duplicate instead of updating the existing one.
The practical recommendation, until Module 6 covers cross-environment promotion in depth: keep the id if your flow is exporting and importing on the same instance (backup and restore). Consider removing it only when the file has to travel between different instances and you'd rather each one manage its own ids. What's beyond doubt are the others —pinData, versionId, meta.instanceId, triggerCount, active— those are pure noise and always go.
Tool 2: Node, if you don't have jq
If jq isn't available on your machine, or you'd rather not install another tool, a short Node script does the same thing. And here an important clarification for this guide: this is a script that runs in your terminal, outside n8n. It isn't a Code node. All of n8n 2.0's Code node restrictions —no require, no filesystem access— don't apply here, because this is regular Node on your computer, with full access to read and write files. The restriction only applies to code inside a workflow.
This is normalize.js:
// normalize.js — normalizes an n8n workflow JSON for clean diffs.
// Usage: node normalize.js order-triage.json
// Runs in your terminal (regular Node), NOT inside n8n.
const fs = require('fs'); // module for reading and writing files
const filePath = process.argv[2]; // the filename passed as an argument
const raw = fs.readFileSync(filePath, 'utf8');
const workflow = JSON.parse(raw); // the JSON text turned into an object
// 1) Remove the volatile fields (the "smoke" that changes on its own).
const volatile = ['pinData', 'versionId', 'active', 'triggerCount'];
for (const key of volatile) {
delete workflow[key]; // delete removes the key from the object
}
if (workflow.meta) {
delete workflow.meta.instanceId; // instanceId lives nested inside meta
}
// 2) Rewrite with the keys sorted stably.
// JSON.stringify's third argument is the indentation (2 spaces) for readability.
const normalized = JSON.stringify(workflow, sortedKeys, 2);
fs.writeFileSync(filePath, normalized + '\n'); // overwrites the file, with a trailing newline
console.log(`Normalized: ${filePath}`);
// This function tells JSON.stringify to walk the keys in alphabetical order.
function sortedKeys(key, value) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return Object.keys(value)
.sort() // stable alphabetical order
.reduce((acc, k) => {
acc[k] = value[k];
return acc;
}, {});
}
return value; // arrays and simple values are left as-is
}
It's run like this:
node normalize.js order-triage.json
What to expect: the terminal prints Normalized: order-triage.json, and the file ends up without volatile fields and with the keys sorted, same as with jq. Node's advantage is that it doesn't depend on installing jq and that the criteria for what to delete is written explicitly, line by line, easy to adjust. jq's advantage is that it's a single line. Choose whichever feels more comfortable; the result is the same.
One detail of the script worth noting: here JSON.stringify rewrites the file directly with writeFileSync over the same filePath, without the temp-file dance. It's safe because Node first read all the content into memory (readFileSync) and only then writes; there's no risk of emptying the file before reading it, unlike with the terminal's > redirection.
Verify your normalization is deterministic
A normalization that works has a property you can check: it's deterministic and idempotent. Deterministic means the same input always gives the same output. Idempotent —a word that sounds strange and describes a simple idea— means applying it twice gives the same result as applying it once: normalizing something already normalized doesn't change it. Like running a comb through already-combed hair: it does nothing new because it's already in order.
It's worth confirming this with a thirty-second check, because if your normalization isn't idempotent, you have a hidden problem that's going to clutter diffs without you knowing why:
# Normalize once.
jq -S 'del(.pinData, .versionId, .active, .triggerCount, .meta.instanceId)' order-triage.json > pass1.json
# Normalize the result again.
jq -S 'del(.pinData, .versionId, .active, .triggerCount, .meta.instanceId)' pass1.json > pass2.json
# Compare the two passes.
diff pass1.json pass2.json
What to expect: diff prints nothing. Total silence means the two files are identical, meaning normalizing a second time changed nothing: your normalization is idempotent. If diff prints differences, something in your process isn't stable —maybe the key order wasn't applied consistently— and it's worth reviewing before trusting it. (diff is the system tool that compares two files line by line; Git uses it internally, but it also works standalone like here.)
This check is your safety net when you tweak the script in the future: every time you change which fields you delete, run the two-pass test. If diff stays silent, your change is safe.
An honest note about timestamps
I listed createdAt and updatedAt among the volatile ones, and they're the ones that vary the most between n8n versions: depending on your version, the CLI export might include them or not, and they might live at the root or inside another object. Don't trust my list blindly: open your own freshly exported order-triage.json and look at which fields it actually has. If you see createdAt or updatedAt, add them to your del(...). If they don't show up, your version doesn't export them and there's nothing to delete. This is the same habit from lesson 2 —verifying against your instance— applied to normalization: the canonical list of volatile fields is the one you discover by looking at your own file, not the one you copy from a guide.
Why this reduces merge conflicts
There's a normalization benefit that isn't obvious until you work on a team: it reduces merge conflicts.
A merge conflict happens when two people change the same line of a file and Git doesn't know which to keep. In a non-normalized JSON, this happens much more than it should, because of the unstable key order: person A saves and the keys end up in one order; person B saves and they end up in another. Now the same lines are in different positions in the two versions, and when you try to merge, Git sees conflicts everywhere —even though each person changed different parts of the logic.
With stable order, every key always lives on the same line. If A changed the AI Agent node's configuration and B changed the HTTP Request node, their changes touch different lines, Git merges them without drama, and there's no conflict. Normalization doesn't just make one person's diff readable; it makes two people's work fit together without fighting.
It's the difference between two people writing in a notebook with fixed, numbered lines, and two people writing on blank sheets that later have to be overlaid. With fixed lines, everyone knows where theirs goes.
A third phantom diff: line endings
There's one more source of noise, one that bites teams with mixed machines especially hard (some on Windows, others on macOS or Linux): line endings. Windows ends every text file line with two invisible characters (CR and LF); macOS and Linux use just one (LF). To the eye, the files look identical. To Git, every line is different, because the invisible end characters changed. Result: if Ana on Windows normalizes and commits, and Beto on Linux opens the same file, he might see "the whole file changed" without anyone having touched the logic.
The cure is a .gitattributes file at the repo's root that fixes a line-ending norm for JSON with Git:
# Normalizes workflow line endings to LF, on any operating system.
*.json text eol=lf
Read it like this: for any file ending in .json, treat it as text (text) and use LF-style line endings (eol=lf), regardless of what system whoever edits it is on. With that, Ana and Beto see the same file even though they work on different systems, and the third phantom diff disappears. It's a one-line file that saves whole afternoons of "but I didn't change anything."
Common mistakes
Emptying the file with jq ... > same-file.json (practical). What happens: you run jq 'del(.pinData)' order-triage.json > order-triage.json and the file ends up empty; your workflow disappears. Why it happens: the terminal opens and empties the destination file before jq reads it, so jq reads a file that's already blank. How to spot it: if after a command like this the file weighs zero bytes, this is it. How to fix it: always write to a temporary file and replace with &&: jq '...' in.json > in.tmp && mv in.tmp in.json. And work with Git: if you already had the workflow committed, git checkout -- order-triage.json recovers it from the last saved version. This is another reason to commit often.
Deleting fields that are actually logic (practical). What happens: someone, excited about cleaning up, adds nodes or connections to the del(...) list and ends up with a file that no longer describes any workflow. Why it happens: the line between "volatile" and "logic" isn't always obvious if you go fast. How to spot it: after normalizing, the file should still have nodes and connections, which are the workflow's heart; if they're not there, you deleted too much. How to fix it: stick to the confirmed list of volatile fields (pinData, versionId, meta.instanceId, triggerCount, active) and don't add anything to del() without being sure that field isn't logic. When in doubt, don't delete it: an extra field in the diff is annoying; a missing logic field breaks the workflow.
Normalizing by hand, once, and believing it's already solved (conceptual). What happens: someone cleans the JSON by hand in a text editor once, commits it, and on the next export the noise comes back, because the manual cleanup doesn't repeat itself. Why it happens: normalization only works if it's applied every time you export; done once, it's a mirage. How to spot it: if your normalizing process depends on you remembering to delete fields by hand, it isn't reproducible. How to fix it: save the command (jq) or the script (normalize.js) and run it on every export. Better yet: wait for lesson 7, where that script gets hooked automatically after exporting, so you never depend on your memory.
Forgetting that key order matters too (conceptual). What happens: someone deletes the volatile fields but doesn't order the keys, and keeps seeing phantom diffs where "everything changed" without anything having changed. Why it happens: it's easy to focus on the volatile fields and forget the second problem, the unstable order. How to spot it: if the diff flags lines as moved with no content change, it's the order. How to fix it: make sure your normalization orders the keys —-S in jq, the sortedKeys function in Node. Removing volatile fields without ordering is half a normalization.
Exercises
Exercise 1 — Classify volatile or logic. For each of these seven fields in a workflow's JSON, say whether it's volatile (removed in normalization) or logic (stays), and why in a few words: (a) nodes; (b) versionId; (c) connections; (d) pinData; (e) meta.instanceId; (f) name; (g) triggerCount.
See solution
(a) nodes — logic. It's each node's definition; the workflow's heart. Stays.
(b) versionId — volatile. n8n regenerates it on every save. Goes.
(c) connections — logic. Describes how the nodes connect to each other. Stays.
(d) pinData — volatile. Test data pinned while editing. Goes.
(e) meta.instanceId — volatile. Identifies your server, not the workflow. Goes.
(f) name — logic (or at least, stable identity). It's the workflow's name; doesn't change on its own. Stays.
(g) triggerCount — volatile. Internal counter that changes with use. Goes.
Why it works: the mental test for classifying is a single question —"does this field change when I DON'T change the logic?" If the answer is yes (versionId, pinData, instanceId, triggerCount), it's volatile. If it describes what the workflow does (nodes, connections, name), it's logic. Having that question sharpened is what lets you decide confidently when facing a field this lesson didn't list.
Exercise 2 — Write the jq command. Write the complete jq command that normalizes weekly-report.json: sorting the keys and deleting pinData, versionId, active, triggerCount, and meta.instanceId, writing the result safely over the same file.
See solution
jq -S 'del(.pinData, .versionId, .active, .triggerCount, .meta.instanceId)' weekly-report.json > weekly-report.tmp && mv weekly-report.tmp weekly-report.json
The key pieces: -S sorts the keys; del(...) with the five fields, comma-separated, removes the volatile ones; .meta.instanceId with the dot goes down to the nested field without touching the rest of meta; and the temp file with > ....tmp && mv avoids emptying the original. If you wrote > weekly-report.json directly, review the "emptying the file" mistake: that command deletes your workflow.
Why it works: this is, almost word for word, the command you're going to put into lesson 7's script. Writing it from memory now means that when you see it inside the script, you'll already recognize it instead of having to decipher it.
Exercise 3 — Predict the diff. You have order-triage.json normalized and committed. You open the workflow in the editor, change an If node's threshold from 1500 to 2000, save, export, and normalize with the same command. What do you expect to see in git diff? And if instead of changing the threshold you had only opened and saved without touching anything?
See solution
If you changed the threshold, git diff shows exactly one changed line: the one that had 1500 now says 2000, inside that node's configuration. Nothing more. That's the dream: the diff tells the real story of the change.
If you only opened and saved without touching anything, git diff is empty. In the raw export, n8n would have changed the versionId (and maybe some timestamp), but since normalization removes them, the normalized file is identical to the committed one. Git sees no difference because, in what matters, there isn't one.
Why it works: these two scenarios are the litmus test proving normalization works. A real change produces a minimal, readable diff; a non-change produces an empty diff. If on your instance you see something different —extra lines in the second case, for example— it's a sign some volatile field slipped past your del() list, and you already know how to hunt it: look at which line the diff flagged and add it if it's really noise.
Summary and next step
In this lesson you saw why a workflow's raw JSON gives dirty diffs: volatile fields n8n changes on its own —pinData, versionId, meta.instanceId, id, active, triggerCount, timestamps— and the unstable key order, which produces phantom diffs where "everything changed" without anything changing. The fix is to normalize: remove the volatile fields and order the keys stably, so two exports of the same logic give the same text byte for byte. You did it two ways —with jq -S 'del(...)', being careful not to empty the file with the > redirection, and with a normalize.js Node script that runs outside n8n without the Code node's restrictions— and you saw how normalization, besides making one person's diff readable, reduces merge conflicts when two people work together.
Before moving on you should be able to: name four volatile fields and explain why they're noise; explain what the key-order phantom diff is; write from memory the jq command that normalizes a file without emptying it; and predict that a save with no logic changes produces, after normalizing, an empty diff.
Lesson 5 makes the leap from "clean material" to "material with shape." You already have workflows exported, secret-free, and normalized, but all piled up with ugly id names. You're going to structure the cumbre-automations repository with a professional layout —workflows/, credentials/ (schema only, never values), docs/, scripts/, .env.example, README— and map n8n's folders and tags to that layout, so another developer opens the repo and understands it at a glance.
Resources
- jq Manual — jqlang.org — jq's official reference: the
--sort-keys(-S) flag, thedel()function, and the entire filter syntax. - Download jq — jqlang.org — how to install jq on macOS, Linux, and Windows.
- Understand n8n's data structure — n8n Docs — which fields make up a workflow's JSON, for telling logic apart from metadata.
- JSON.stringify — MDN — how the third argument (indentation) and the replacer function we use in
normalize.jsto sort keys work. - git diff — Git Documentation — the tool normalization makes readable; useful for reviewing how Git compares text line by line.