Most error handling threads here, including ones I have posted in, are about executions that fail. Error Trigger, retries, backoff, a central error workflow. All correct, and all of it misses the failure that cost me the most time.
A step ran. It returned successfully. It returned nothing.
Mine was a retrieval step feeding an AI Agent. A filter node downstream of it had a condition left over from an earlier fix, and after a schema change that condition stopped matching. Eight rows became zero rows. The agent got empty context, answered from its own priors, and the execution finished green. Nothing threw, so nothing alerted. It was wrong for two days before a human noticed the answers had gone vague.
Empty is not an error, and that is exactly the problem. A successful response carrying an empty array passes every reasonable check you can write, and in most workflows an empty result really is legitimate: nobody asked about that topic, no new rows today, the filter correctly excluded everything.
So the rule cannot be “throw on empty”. I tried that first and it was miserable. You page yourself daily and stop reading your own alerts inside a week.
What worked was splitting one question into three, because they need three different mechanisms.
0. The n8n specific trap you hit first
A node that receives zero items does not execute. So if you put a counter or an assertion directly after the step you are worried about, it never runs in exactly the case you are trying to measure. The run still shows green, and now your check is silent too.
The fix is a node setting rather than code. On the step you want to watch, open Settings and turn on Always Output Data. From the docs: “The node returns an empty item even if the node returns no data during execution.” That single empty item is what lets anything downstream notice. Do not set it on IF nodes, it can loop.
Everything below assumes that setting is on.
1. Is THIS execution wrong? Guard the destructive boundary, and only that
Empty data is harmless right up until it reaches something irreversible. A send, a write, a payment, a delete. Put a hard stop there and nowhere else.
// Code node placed immediately BEFORE the node that sends, writes, pays or deletes.
const rows = $input.all();
const empty =
rows.length === 0 ||
(rows.length === 1 && Object.keys(rows[0].json).length === 0);
if (empty) {
throw new Error(
'BOUNDARY GUARD: empty result reached an irreversible step. Refusing to continue.'
);
}
return rows;
Upstream emptiness is data. Downstream emptiness is a decision. Guarding the boundary instead of the source is what keeps this from becoming alert noise.
2. Is the RATE wrong? Count, never throw
This is the one that actually caught my bug. A single empty execution tells you nothing. A step that has sat at 4 percent empty for months and is suddenly at 100 percent is broken, and no individual execution inside that window looks any different from a legitimate no match.
So stamp every run and look at the series instead of the event.
// Code node placed directly AFTER the step you want to watch.
// Requires "Always Output Data" on that step. Never throws.
const rows = $input.all();
const empty =
rows.length === 0 ||
(rows.length === 1 && Object.keys(rows[0].json).length === 0);
return [{
json: {
workflow: $workflow.name,
step: 'retrieval', // rename per step you instrument
count: empty ? 0 : rows.length,
empty,
execution: $execution.id,
at: new Date().toISOString(),
}
}];
Send that to a Sheet, a Postgres table, whatever you already run. Then alert on the rate, not the event. My threshold is crude and has not needed refining: flag it when the last 20 runs of a step sit more than 3 standard deviations off that step’s own 30 day empty rate.
3. Is it wrong RIGHT NOW, with no traffic to measure? Canary a known answer
Rate monitoring needs volume. At 30 executions a day, a step drifting from 4 percent empty to 40 percent takes better than a week to separate from noise, and it has been wrong that whole time. Credit to @colemaffeo6 for pushing on this in another thread, it is a real gap and the two approaches are not competing.
The fix is cheap. Pick two or three inputs whose correct output you established before the workflow existed, then run them on a schedule against production.
// Schedule Trigger -> HTTP Request (your production webhook) -> this node.
const CASES = [
{ ask: 'What are the opening hours on Sunday?', expect: /closed/i },
{ ask: 'Is there parking on site?', expect: /free|street|garage/i },
// deliberately unanswerable: correct behaviour is admitting it does not know
{ ask: 'Do you sell rocket engines?', expect: /do not know|cannot find|not sure/i },
];
const c = CASES[$runIndex];
const answer = $json.answer ?? '';
if (!c.expect.test(answer)) {
throw new Error('CANARY FAILED on "' + c.ask + '" -> "' + answer.slice(0, 120) + '"');
}
return [{ json: { case: c.ask, passed: true } }];
The discipline that makes this work: write the expected answers by hand, from the source documents, before you look at what the workflow returns. If you generate them from its own output you have simply written down whatever it currently does, bug included.
Keep at least one negative case, like the third one above. Those catch the opposite failure, an agent that started inventing answers because retrieval went quiet underneath it.
Why three and not one
Rate monitoring catches slow drift where there is enough volume to measure it. Canaries catch a break in a single run no matter how little traffic you have. The boundary guard makes sure that while you are still deciding which of those you need, nothing irreversible happens on empty data.
None of it is clever. It is just refusing to treat “it ran” as “it worked”.
Happy to go deeper on any part of this. I do this kind of review as paid work, but there is nothing held back above, the method is the whole of it, so take it and run it yourself.