Module 4: The System's Data Model

2. Static Data, variables, and why they aren't enough

Description

By the end of this lesson you'll be able to explain, with a concrete proof, why $getWorkflowStaticData() and n8n's variables aren't a reliable place for an idempotent system's truth. You're going to know exactly what Static Data is, what its name promises and where that promise breaks. You're going to learn the fact that surprises almost everyone —that Static Data isn't saved when you test the workflow from the editor— and you're going to have the complete list of its limits, each with the reason it disqualifies Static Data as a home for deduplication. And you're going to walk away with the criterion to know, for any data you want to "save between executions," whether an internal n8n mechanism is enough or you need a database.

This matters because it's the number-one temptation for whoever reaches this problem. You finish module 2, understand idempotency needs memory, search n8n for "how do I save something between executions," and the first thing you find is $getWorkflowStaticData(). The name is an invitation: "static workflow data," something that sounds permanent. Thousands of tutorials use it to keep track of the last processed item from an RSS feed, and it works well enough in that case to look like the general answer. It isn't, for idempotency, and this lesson is what saves you discovering it in production, with duplicate charges along the way.

Connection to the module: this is the lesson that makes everything that follows inevitable. Lesson 1 laid out that the system's truth has to live outside the execution; this one demonstrates why the mechanisms n8n offers inside the workflow don't close that gap. When you finish, the database —the Starter Kit's Postgres— stops being "an option" and becomes the only serious answer, and lessons 3, 4, and 5 build it knowing exactly which problem each piece solves. This is the argument; the rest of the module is the solution.

The sticky note taped to the monitor

Let's start with an image, because Static Data's concept is understood better through what it seems to be before seeing what it is.

Imagine you keep track of something important on a sticky note taped to the edge of your monitor. Every time you finish a task, you cross it out and write the new number. While you're at your desk, it works great: the note is there, you read it, you update it. It's fast, it's within reach, you didn't have to open any program.

The sticky note's problem doesn't show up when you use it. It shows up at the edges. If you work at another desk that day, the note isn't there —it stayed on your monitor—. If the cleaning crew throws it away thinking it's trash, your count disappears with no warning. If someone else needs that number, they can't read it: it's your note, on your monitor. And if someone asks you "are you sure that number is right?", the honest answer is "it's fine as long as nobody's touched the note, and I have no way to guarantee nobody has."

Static Data is n8n's sticky note. It's a space where a workflow can store a small piece of data and read it back later. It's convenient, it's built in, it requires setting up nothing. And it has exactly the sticky note's problems: its persistence depends on conditions you don't fully control, it doesn't share well, and you can't guarantee its integrity when something touches it at the edge. For keeping track of something not very critical —the timestamp of the last email you checked— the sticky note is enough. For the truth you depend on not to charge twice, it isn't.

Let's see precisely what it is, and then why those edges disqualify it for idempotency.

What Static Data exactly is

In n8n, inside a Code node, you have access to a function: $getWorkflowStaticData(). The official documentation describes it as the mechanism that "gives access to the workflow's static data" and with which "you can save data directly in the workflow."

The anatomy is this. The function returns you an object —a kind of empty drawer the first time— where you can write properties. Whatever you write there, n8n will try to keep for the next execution.

// Code node — read and write Static Data
const staticData = $getWorkflowStaticData('global');

// Read what was left from a previous execution (undefined the first time)
const lastId = staticData.lastProcessedId;

// Write a new value for the next execution
staticData.lastProcessedId = 'ORD-2041';

There are two variants, depending on the argument you pass, and it's worth understanding them because one of the two makes the problem worse:

$getWorkflowStaticData('global') — the drawer shared by the whole workflow. Any node in the workflow can read and write there. The documentation puts it this way: "Global static data is the same in the whole workflow. Every node in the workflow can access it."

$getWorkflowStaticData('node') — a node's private drawer. Only that node can read back what it wrote. The documentation: "The node static data is unique to the node. Only the node that set it can retrieve it again."

Notice the word appearing in both definitions and nowhere else: workflow. Static Data's scope is, best case, a single workflow. There's no $getWorkflowStaticData('everything') variant that shares the data between different workflows. This is the first edge, and we come back to it below.

The use Static Data is designed for —and where it's reasonable— is storing a small piece of data marking progress. The documentation itself gives the example: storing the timestamp of the last processed item from an RSS feed or a database, so the next execution only brings what's new. A number, a date, an identifier. Nothing big, nothing critical.

Worked example: trying to deduplicate order-triage with Static Data

Let's do what anyone who just learned about the function would do: use it to solve the module's problem. The idea is direct —store in Static Data the list of order_ids we already processed, and check it before creating the charge—.

// ============================================================
// Node: Code — "Dedup with Static Data" (ATTEMPT — do not use this for real)
// Mode: Run Once for All Items
//
// IDEA:  store the order_ids already seen in Static Data,
//        and discard the order if it is already in the list.
// ============================================================

const staticData = $getWorkflowStaticData('global');

// The first time it does not exist, so I start with an empty list
const seen = staticData.seenOrderIds || [];

const order = $input.first().json;
const orderId = order.order_id;

if (seen.includes(orderId)) {
  // Already seen: mark the order as a duplicate
  return [{ json: { ...order, is_duplicate: true } }];
}

// Not seen before: add it to the list and let the order through
seen.push(orderId);
staticData.seenOrderIds = seen;

return [{ json: { ...order, is_duplicate: false } }];

Reading it, it looks correct. You store the seen IDs, check before acting, add the new one. It's the literal translation of "check whether you already did it, and if not, do it and note it down."

What to expect when you test it from the editor. Here comes the surprise that ruins many people's day. You open the workflow in n8n's editor, run it with order ORD-2041, and the node returns is_duplicate: false. Correct, it's the first time. You run it again, the same ORD-2041, expecting it now to say is_duplicate: true. And it returns is_duplicate: false again. And again. And again. Static Data doesn't remember anything between one test execution and the next.

It isn't a bug in your code. It's the documented behavior, and it's reason number one Static Data doesn't work for what you're trying to do. The official documentation says it bluntly:

"Static data isn't available when testing workflows. The workflow must be published and called by a trigger or webhook to save static data."

In other words: Static Data isn't available when testing workflows. The workflow has to be published (active) and called by a trigger or a webhook for Static Data to be saved. Manual executions from the editor —exactly the ones you use to develop and debug— don't save Static Data.

Pause a second on what this means for your work. The place where you build and test your deduplication logic is exactly the place where that logic doesn't work, with no error warning you. You debug, you see is_duplicate: false repeated, and you have no way to tell "my code is wrong" apart from "Static Data doesn't persist in this mode." It's the worst possible combination: it fails silently, and it fails exactly where you'd look to diagnose it.

You might say: "fine, so I activate it and test it with the real webhook." And yes, there it starts persisting. But that only leads you to the next edge, which is worse, because you no longer see it coming.

The edges that disqualify Static Data for idempotency

Suppose you activated the workflow and now Static Data does persist between production executions. Is it solved? No. Static Data has a list of limits, and each one, on its own, would be enough to rule it out as a home for deduplication. Together, they close the case.

1. No atomicity: the "check then act" trap is still open. This is the deepest one, and it connects directly to what module 2 flagged. Your code does three things in sequence: it reads the seen list, decides whether the ID is there, and writes the list with the new ID. Time passes between those steps. If two triggers for the same ORD-2041 run almost together —the real case of the webhook firing double—, both can read the list before either has updated it, both see "not there," and both create the charge. Static Data has no way to say "check-and-insert in a single indivisible step." A database with a uniqueness constraint does, and that's exactly lesson 5's solution. Static Data reproduces the race; it doesn't resolve it.

2. The scope is a single workflow. Static Data lives inside the workflow that wrote it. If order-triage marks ORD-2041 as seen, and another workflow —say order-refund or a new version of the triage— needs to know that order has already been processed, it can't read it. The truth "this order has already been charged" is a system truth, not a workflow's; it should be queryable from any flow that needs it. A table in Postgres is seen by every workflow you want; one workflow's sticky note, only by that workflow.

3. It behaves unreliably under frequent executions. The documentation itself warns this function "may behave unreliably under high-frequency executions." An order webhook that sometimes gets bursts is exactly a high-frequency case. The mechanism meant for "storing the timestamp of an RSS you check hourly" isn't designed to be the referee of correctness in a flow that can receive several events per second.

4. It's meant for small data. The "seen" list grows with every order. If you store there every order_id in Cumbre's history, that sticky note becomes an enormous poster n8n has to read, load into memory, and rewrite on every execution. Static Data is explicitly meant for small data —a timestamp, a cursor—, not a record that grows forever. A table, by contrast, is built for millions of rows and queries them by key instantly.

5. It's fragile against workflow changes. Static Data travels glued to the workflow. When you export and import the workflow —to move it between environments, to version it, to restore a backup—, that internal state might not travel with it, or might do so inconsistently. Think of it this way: you're storing the system's truth inside the file of the program that processes it, and that file gets copied, edited, and reimported as part of normal work. Every one of those operations is a chance for the sticky note to come unstuck. The system's truth has to live in a place separate from the code that uses it, precisely so you can change the workflow without touching the truth, and back up the truth without depending on the workflow.

Put all five together: it isn't atomic (it reproduces the race), it isn't shared between workflows, it's unreliable at high frequency, it doesn't scale in size, and it breaks when you move the workflow. And add the one that opened the lesson: it isn't even saved when you test it from the editor. It isn't that Static Data is "bad" —for its design case, a small cursor in a low-frequency active workflow, it's perfectly useful—. It's that idempotency asks it for exactly the five things it doesn't give.

What about variables? $vars isn't the place either

The other temptation is $vars, project variables. If $env is no longer there and you search "where do I put a value I want to read," $vars shows up. It's worth closing that door too, because the mistake is subtle: $vars solves a similar but different problem.

$vars is for configuration, not state. The difference is the same one from lesson 1: configuration is a value someone on the team sets and that rarely changes —a threshold, a URL, a limit—; state is something the system keeps writing as it works —which orders it already processed—. And $vars has three properties making it impossible as state, even setting aside that on Community with no license it isn't even available:

  • It's read-only from the workflow. Your code can read $vars.free_shipping_threshold, but it can't write $vars.seenOrderIds = [...]. A mechanism you can't write to can't be where you record what you've already seen. The discussion ends right there: deduplication needs to write.
  • Every value arrives as text. Even if you could write, you'd have to serialize and parse by hand.
  • It's meant for one-off configuration values, with size limits, not for a record that grows.

The rule, then, fits in one line: $vars stores what you decide once; a database stores what the system discovers as it works. Deduplication is the second. $vars isn't its place, just as Static Data isn't.

What a database gives you and these mechanisms don't

It's worth closing by putting each limit and its solution side by side. This table is, at bottom, the plan for the next three lessons.

What idempotency needsStatic Data / $varsPostgres (lessons 3-5)
Persist when testing from the editorNo (Static Data isn't saved in test mode)Yes, writes every time you run the query
"Check-and-insert" in a single atomic stepNo (reproduces the race)Yes, with INSERT ... ON CONFLICT (lesson 5)
Share the truth across several workflowsNo (single-workflow scope)Yes, any workflow can query the table
Reliability under high frequencyDocumented as unreliableDesigned exactly for that load
Grow to many recordsNo (meant for small data)Yes, millions of rows, queried by key
Survive exporting/reimporting the workflowFragile (travels glued to the workflow)Yes, lives separate from the workflow
Write state from the workflowStatic Data yes / $vars noYes, with the Postgres node

Notice the middle column: every row has a "no" or a "fragile." It isn't that we chose criteria favoring Postgres; they're, literally, the requirements of deduplication across executions, and the internal mechanisms all fail. That's why the module is built on a database and not on a trick inside the workflow.

An honesty to close with, because teaching only one side would be teaching poorly: Static Data exists for good reasons and has its place. A "last item read" cursor in a low-frequency active workflow that doesn't share that data with anyone is its ideal case, and there it's simpler than setting up a table. The lesson isn't "Static Data is garbage"; it's "Static Data isn't where an idempotent system's truth lives." Knowing how to tell the two cases apart —when a small cursor is enough and when you need a database— is part of the judgment separating someone who assembles flows from someone who owns a system.

Common mistakes

Debugging dedup logic in the editor and concluding "it doesn't work" (practical). What happens: you write your deduplication with Static Data, test it from the editor with the same ID several times, it always says "first time," and you spend an hour hunting for the bug in your code. Why it happens: Static Data isn't saved in manual executions, so every test starts with the drawer empty; your code is fine, the mechanism just doesn't persist in that mode. How to spot it: if your state "resets" every time you run it from the editor with no errors at all, this is it. How to fix it: remember Static Data only persists in active workflows triggered by a trigger or webhook; but more fundamentally, don't use Static Data for deduplication —use a table, which persists when you test too—.

Confusing "it works for the RSS" with "it works for idempotency" (conceptual). What happens: you see dozens of tutorials using Static Data to track a feed's cursor and assume it works the same for not duplicating charges. Why it happens: both cases sound like "saving something between executions," but they're different: the RSS cursor has no critical race and demands no atomicity, and an error there at worst re-reads an old item. An error in charge dedup duplicates money. How to spot it: ask yourself what happens if the mechanism fails; if the answer is "I reprocess a harmless piece of data," Static Data might be enough; if it's "I charge twice," it isn't. How to fix it: reserve Static Data for low-risk cursors and move to Postgres anything that, on failure, produces an effect you can't easily undo.

Putting the system's state in $vars because it's "where you put values" (conceptual). What happens: someone tries to save the seen list in a project variable. Why it happens: $vars is the most visible thing that looks like a "value store" in n8n. How to spot it: $vars is read-only from the workflow, so the moment you try to write, you can't; if your design requires writing to $vars, you already know you're on the wrong path. How to fix it: $vars is configuration you decide once; the state the system discovers as it works goes in a database.

Storing a list that grows forever in Static Data (practical). What happens: deduplication with Static Data "works" in an active workflow for a while, and over months the workflow gets slow or behaves strangely. Why it happens: the seen list grows with every order, and Static Data is meant for small data; n8n loads and rewrites that blob on every execution. How to spot it: if your Static Data stores a collection that only grows, this is it. How to fix it: a table indexed by key queries "does ORD-2041 exist?" in constant time with no need to load the whole history; it's exactly what it's for.

Not separating the system's truth from the workflow's file (conceptual). What happens: the workflow gets versioned and reimported as part of normal work, and at some point deduplication "resets" or behaves differently with nobody having touched the logic. Why it happens: the state lived inside the workflow, and it traveled inconsistently on export/import. How to spot it: if your state changes when you move the workflow between environments, it's coupled to the file. How to fix it: the system's truth lives in a separate database; that way you can change, version, and restore the workflow without touching the state, and back up the state without depending on the workflow.

Exercises

Exercise 1 — Name the edge. For each situation, say which of Static Data's limits is responsible, in one sentence.

(a) You test your dedup from the editor and the same ID never shows up as a duplicate. (b) Two nearly simultaneous triggers for the same order create two charges, even though the workflow is active. (c) A second workflow needs to know whether order-triage already processed an order, and has no way to find out. (d) Over months, the workflow using Static Data for dedup gets slow.

See solution

(a) Doesn't persist when testing from the editor. Static Data only saves in production executions (trigger/webhook with the workflow active). In test mode, every run starts with the drawer empty.

(b) Lack of atomicity. Both triggers read the list before either updates it, both see "not there," and both act. It's the "check then act" race, which Static Data can't close because it offers no indivisible "check-and-insert."

(c) Single-workflow scope. Static Data lives inside the workflow that wrote it; another workflow can't read it. The truth "this order has already been processed" belongs to the system and should live somewhere everyone can query.

(d) Meant for small data. The seen list grows forever, and Static Data loads and rewrites that whole blob on every execution. An indexed table doesn't have that cost.

Why this works: notice each symptom maps to a different edge. It isn't that Static Data "sometimes fails": it fails in specific, predictable ways, and each has a name. Recognizing which is which is what lets you quickly decide whether an internal mechanism is enough for a case or not.

Exercise 2 — Static Data or database? For each piece of data, decide whether Static Data would be a reasonable place or whether it demands a database, and justify in one sentence.

(a) The timestamp of the last email a reporting workflow checked, in a flow that runs once an hour. (b) The list of order_ids already charged, in an order flow that can receive bursts. (c) The consecutive folio number to be assigned to every new invoice, never repeating. (d) The "last page read" cursor of a paginated API, within a single long execution.

See solution

(a) Static Data is reasonable. It's a small, low-frequency cursor, shared with nobody, whose worst failure case is re-reading a few emails. It's Static Data's design case.

(b) Database, no question. High frequency, demands atomicity (two nearly-simultaneous triggers), the list grows, and on failure it duplicates a charge. Each of those traits, on its own, already rules out Static Data.

(c) Database. A folio that "never repeats" is precisely what a uniqueness constraint and a database sequence guarantee; with Static Data, two concurrent executions could read the same last folio and assign the same number.

(d) Neither Static Data nor a database: it's execution state. If the cursor only lives within an execution paginating until it finishes, it's a normal node variable; it doesn't need to survive the execution. Confusing this and putting it in Static Data is over-storing.

Why this works: the usual question —does it need to survive the execution?— decides between execution state and system state. And among the ones that do survive, a second question —is its failure harmless or costly, and is there a race?— decides between the sticky note (Static Data) and the serious notebook (database).

Exercise 3 — Explain it in code review. A coworker shows you their deduplication solution: they store seen order_ids with $getWorkflowStaticData('global'), tested it in the editor "and sometimes it works and sometimes it doesn't," and asks you for help. Write the answer you'd give them, covering: (a) why "sometimes it works and sometimes it doesn't," (b) why, even if they activate it, it would still be unreliable for dedup, and (c) what you'd propose.

See solution

A reference version. Notice it first explains the exact symptom they saw, and only then generalizes.

(a) Why "sometimes yes, sometimes no." It's almost certainly the execution mode. Static Data isn't saved in test executions from the editor: it only persists when the workflow is active and triggered by a trigger or a webhook. If some of your tests were manual and others with the real webhook, that's exactly what you'd see: in the manual ones it never remembers, in the production ones it does. It isn't random, it's that difference.

(b) Why activating it doesn't fix it. Even if you get it to persist, dedup with Static Data has a deeper hole: it isn't atomic. Your code reads the list, decides, and writes, in three steps. If two triggers for the same order arrive nearly together —exactly the case we want to cover—, both read "not there" before either writes, and both create the charge. Static Data offers no "check-and-insert" in a single step. It also lives glued to this workflow, so no other flow can query it, and it grows unboundedly with every order.

(c) What I propose. Move the truth to a table in the Postgres the Starter Kit already brings. A processed_orders table with idempotency_key as a unique column, and before charging we do INSERT ... ON CONFLICT DO NOTHING: if the row got inserted, it's the first time and we charge; if it collided with the constraint, it's a duplicate and we do nothing. That "insert or collide" is a single indivisible step, so it resolves the race Static Data can't. And as a bonus, the table also persists when you test from the editor, so you stop depending on the execution mode to develop.

Why this works: the answer doesn't stop at "Static Data is wrong." It explains the concrete symptom (test mode), rises to the underlying problem (atomicity), and lands on the module's exact solution (ON CONFLICT on a unique column). It's the conversation that turns "it sometimes works" into a diagnosis and a plan.

Summary and next step

In this lesson you closed the argument making the rest of the module inevitable. $getWorkflowStaticData() —n8n's sticky note— looks like the natural answer to "save something between executions," and for a small, low-frequency cursor, it is. For an idempotent system's truth, it isn't. You proved it yourself: when deduplicating order-triage with Static Data from the editor, the same ORD-2041 never shows up as a duplicate, because Static Data isn't saved in test executions —it only persists in active workflows triggered by a trigger or webhook—.

And even if you activate it, the edges disqualifying it remain: it isn't atomic (it reproduces the "check then act" race), its scope is a single workflow, it behaves unreliably at high frequency, it's meant for small data, and it's fragile on export/import of the workflow. $vars isn't the place either: it's read-only configuration, not state the system writes.

The conclusion isn't that Static Data is wrong, but that an idempotent system's truth has to live in a real database, separate from the workflow. And you already have it: the Starter Kit v2's Postgres. Every limit you saw —atomicity, shared scope, reliability, size, separation from the code— is something a relational database gives out of the box.

Before moving on you should be able to: cite the fact that Static Data doesn't persist when testing from the editor; name at least three of its five edges; and explain why $vars doesn't work for state.

Lesson 3 starts building the solution. You're going to design the run ledger: the table noting what ran, with what idempotency key, in what state —pending, done, or failed— and with what result. It's the system's complete notebook, the one that doesn't just say "this already happened" but "this happened, at this time, and ended like this." With it, the question "did I already process ORD-2041?" finally has a reliable place to live.

Resources

  • getWorkflowStaticData — n8n Docs — the source for the key behavior: what Static Data is, the difference between 'global' and 'node', and the warning that it isn't available when testing workflows and only saves with the workflow active, triggered by trigger or webhook.
  • Define custom variables — n8n Docs$vars: availability by plan, that every variable is text and read-only. Useful for confirming why they don't work as state the system writes.
  • Postgres node — n8n Docs — the node you're going to use, starting in lesson 3, to read and write the system's truth in a real database.
  • Deploy with the AI starter kit — n8n Docs — the local stack that already brings the Postgres where that truth is going to live, at zero cost.
  • Understand n8n's data structure — n8n Docs — the structure of items traveling between nodes, which is execution state (cleared when it ends), to keep the boundary with system state (persists) clear.