Part
2
  |  
Building Blocks
  |  
Chapter
5

The Code Node Patterns I Reach For in Every Production Workflow

The Code node is the most powerful primitive in n8n and the one most teams use wrong. Either they avoid it entirely and end up with 14-node Set chains that snake across the canvas like spaghetti, or they shove…
Reading Time
13
mins
BACK TO n8n Workflow ENgineer

The trap is thinking the Code node is a last resort. Teams treat it as the thing you use when n8n doesn't have a native integration, or as a junk drawer for business logic that doesn't fit anywhere else. I treat it as a precision tool. Small, sharp Code nodes do the work that declarative nodes cannot: reshaping nested JSON, aggregating across batches, generating deterministic idempotency keys, and parsing malformed vendor files.

The canvas handles orchestration — retries, auth, branching, error routing. The Code node handles transformation that requires logic.

The boundary I use is simple. If a task needs more than three Set nodes in a row, that's a Code node. If a calculation needs to see every item in a batch at once, that's a Code node. If an API lacks a native node and returns cursor-based pagination, that's a Code node. Everything else stays on the canvas where it belongs.

Here are the patterns I actually deploy.

The 3-Set Rule: When to Switch from Nodes to Code

Framework · The 3-Set rule

If you need more than three Set or Edit Fields nodes in a row to reshape the same payload, stop. You're fighting the tool. The canvas is for orchestration, not for writing procedural data transformation scripts with boxes and arrows.

The worst offender is deeply nested API responses. Stripe, Shopify, Salesforce — these webhooks bury the data you need three or four levels deep. I've watched teams chain Set nodes to extract invoice.data.object.lines.data[0].description, then another Set node for the amount, then an IF node to check if the key exists because the first Set node crashes on missing data. Optional chaining and nullish coalescing were invented to solve this exact problem, and you can't use them in expression fields.

My default pattern is a single Code node in "Run Once for All Items" mode that flattens the entire payload in one pass:

const results = [];

for (const item of $input.all()) {
  const invoice = item.json;
  const lineItems = invoice?.data?.object?.lines?.data ?? [];

  for (const line of lineItems) {
    results.push({
      json: {
        invoiceId: invoice.data?.object?.id,
        customerId: invoice.data?.object?.customer,
        customerEmail: invoice.data?.object?.customer_email,
        lineDescription: line.description,
        amount: line.amount ? line.amount / 100 : 0,
        currency: line.currency?.toUpperCase() ?? 'USD',
        periodStart: line.period?.start
          ? new Date(line.period.start * 1000).toISOString()
          : null,
        priceId: line.price?.id ?? 'unknown',
        quantity: line.quantity ?? 1,
      }
    });
  }
}

return results;

This replaces five or six nodes. More importantly, it handles missing keys safely. If lines.data is absent, the loop simply doesn't run. If price.id is missing, it defaults to 'unknown'. Achieving that same resilience with Set and IF nodes requires a separate error branch for every field, which is why teams end up with those 14-node chains.

Aggregate Across All Items with $input.all()

Set nodes, Edit Fields nodes, and most native transformations operate on one item at a time. That's fine until you need to compute a total, find a maximum, or group records by category across the entire batch. I've seen teams try to solve this by accumulating values in workflow static data across a loop, which works until two executions run concurrently and overwrite each other's counters.

The right tool is $input.all() in "Run Once for All Items" mode. It gives you the full array of items upfront. From there, standard JavaScript reduce, filter, and Map objects do the work in milliseconds.

A pattern I use constantly is post-ingest summarization. After pulling all line items from an accounting API, I need a single summary object with total revenue, tax collected, and a breakdown by product category:

const items = $input.all().map(i => i.json);

const summary = items.reduce((acc, line) => {
  acc.totalRevenue += line.amount ?? 0;
  acc.totalTax += line.taxAmount ?? 0;
  acc.itemCount += 1;

  const cat = line.category || 'Uncategorized';
  if (!acc.byCategory[cat]) {
    acc.byCategory[cat] = { revenue: 0, count: 0 };
  }
  acc.byCategory[cat].revenue += line.amount ?? 0;
  acc.byCategory[cat].count += 1;

  return acc;
}, { totalRevenue: 0, totalTax: 0, itemCount: 0, byCategory: {} });

// Convert the category map to an array for downstream nodes
summary.categoryBreakdown = Object.entries(summary.byCategory).map(
  ([name, data]) => ({ category: name, ...data })
);
delete summary.byCategory;

return [{ json: summary }];

The output is a single item that feeds cleanly into a Postgres insert, a Slack message, or an email report. The alternative — trying to aggregate in the canvas — usually involves a Loop node, a Set node pretending to be an accumulator, and a prayer that the execution doesn't get interrupted. $input.all() eliminates that entirely.

The Utility Belt Pattern: Top-of-File Helpers

Once a Code node crosses about twenty lines, readability drops fast.

Framework · The utility belt pattern

A block of small, pure helper functions at the top of the Code node, followed by the main logic at the bottom. Keeps the transformation readable. Makes the node self-documenting. When you copy the same belt into three workflows, promote it to a sub-workflow.

In my experience, the same formatting problems show up in every CRM sync: phone numbers arrive as (555) 123-4567, 555.123.4567, or 15551234567; emails have trailing spaces; names are ALL CAPS. I define the helpers once per node:

// --- Utility Belt ---
function normalizePhone(raw) {
  if (!raw) return null;
  const digits = String(raw).replace(/\D/g, '');
  if (digits.length === 10) return `+1${digits}`;
  if (digits.length === 11 && digits.startsWith('1')) return `+${digits}`;
  return digits.length >= 10 ? `+${digits}` : null;
}

function isValidEmail(email) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email ?? '');
}

function titleCase(str) {
  if (!str) return '';
  return str.toLowerCase().replace(/\b\w/g, c => c.toUpperCase());
}

function buildFullName(first, last, company) {
  const name = [first, last].filter(Boolean).map(titleCase).join(' ');
  return name || company?.trim() || 'Unknown';
}

// --- Main Logic ---
return $input.all().map(item => {
  const c = item.json;
  return {
    json: {
      fullName: buildFullName(c.firstName, c.lastName, c.company),
      email: isValidEmail(c.email) ? c.email.toLowerCase().trim() : null,
      phone: normalizePhone(c.phone),
      company: c.company?.trim() || null,
      isValid: isValidEmail(c.email) && !!normalizePhone(c.phone),
    }
  };
});

If I find myself copying these same four functions into three different workflows, I promote them. The quickest promotion path is a utility sub-workflow that every parent workflow calls via the Execute Sub-Workflow node. For logic that changes rarely — like phone normalization — I keep a snippet file in my notes and paste it in. The key is that no Code node should grow into an unreadable wall of inline transformations. Separate the mechanics from the policy.

Deduplication with Map

The Remove Duplicates node is fine for small datasets. At 1,000 items it is tolerable. At 10,000 it slows down. At 100,000 items it risks timing out. When I need to deduplicate large batches — especially when merging contacts from multiple CRMs or processing event streams — I use a Code node with a native JavaScript Map.

The advantage is control and speed. A Map lookup is O(n). You define your own composite key, normalize it on the way in, and decide your own merge strategy. My default strategy when merging contact records is to keep the record with more populated fields.

const seen = new Map();
const duplicates = [];

for (const item of $input.all()) {
  const email = (item.json.email ?? '').toLowerCase().trim();
  const lastName = (item.json.lastName ?? '').toLowerCase().trim();
  const key = `${email}|${lastName}`;

  if (!key || key === '|') continue;

  if (seen.has(key)) {
    const existing = seen.get(key);
    const existingScore = Object.values(existing.json).filter(Boolean).length;
    const currentScore = Object.values(item.json).filter(Boolean).length;

    if (currentScore > existingScore) {
      duplicates.push(existing);
      seen.set(key, item);
    } else {
      duplicates.push(item);
    }
  } else {
    seen.set(key, item);
  }
}

const results = Array.from(seen.values());
results.push({
  json: {
    _metadata: true,
    totalInput: $input.all().length,
    uniqueOutput: seen.size,
    duplicatesRemoved: duplicates.length,
  }
});

return results;

On a batch of roughly 100,000 items, this runs in about 600 milliseconds. The Remove Duplicates node can take an order of magnitude longer, and it doesn't let you define a composite key or a merge heuristic. For production workflows that process large syncs daily, this pattern is the difference between a two-minute execution and a twenty-minute execution.

Date Math That Expressions Cannot Handle

n8n expressions support Luxon, which is enough for {{ $now.plus({ days: 7 }) }}. The moment you need business-day logic, fiscal quarters, or timezone-aware scheduling windows, the expression language falls apart. I've seen workflows with six DateTime nodes, four IF nodes, and a Set node trying to calculate "the next business day that is at least 30 days out, skipping US federal holidays." It is a maintenance nightmare.

I move that logic into a Code node immediately. Here's a pattern I use for contract renewals and SLA calculations:

const holidays2025 = new Set([
  '2025-01-01', '2025-01-20', '2025-02-17', '2025-05-26',
  '2025-06-19', '2025-07-04', '2025-09-01', '2025-10-13',
  '2025-11-11', '2025-11-27', '2025-12-25',
]);

function isBusinessDay(date) {
  const day = date.getDay();
  if (day === 0 || day === 6) return false;
  return !holidays2025.has(date.toISOString().slice(0, 10));
}

function addBusinessDays(start, days) {
  const result = new Date(start);
  let added = 0;
  while (added < days) {
    result.setDate(result.getDate() + 1);
    if (isBusinessDay(result)) added++;
  }
  return result;
}

const contractEnd = new Date($input.item.json.contractEndDate);
const minDate = new Date(contractEnd);
minDate.setDate(minDate.getDate() + 30);

let renewalDate = new Date(minDate);
while (!isBusinessDay(renewalDate)) {
  renewalDate.setDate(renewalDate.getDate() + 1);
}

const reminderDate = addBusinessDays(renewalDate, -5);

return [{
  json: {
    ...$input.item.json,
    renewalDate: renewalDate.toISOString().slice(0, 10),
    reminderDate: reminderDate.toISOString().slice(0, 10),
  }
}];

This would require an unreasonable number of canvas nodes to replicate. The Code node gives you standard JavaScript Date operations, loops, and lookup sets. That is not cheating; that is using the right tool for calendar arithmetic.

Parsing Malformed CSV and XML

The Spreadsheet File node assumes well-formed data: standard commas, one header row, no quoted delimiters inside fields. Vendor exports rarely cooperate. I receive files with semicolon delimiters, two-line headers, commas inside quoted product names, and XML with namespaces that break the built-in parser.

A Code node gives you full control. Here's a lightweight CSV parser I keep in my utility belt for semicolon-delimited files with quoted fields:

const csvString = $input.first().json.csvData;
const lines = csvString.split('\n').slice(2).filter(l => l.trim());

function parseCSVLine(line, delimiter = ';') {
  const result = [];
  let current = '';
  let inQuotes = false;

  for (const char of line) {
    if (char === '"') {
      inQuotes = !inQuotes;
    } else if (char === delimiter && !inQuotes) {
      result.push(current.trim());
      current = '';
    } else {
      current += char;
    }
  }
  result.push(current.trim());
  return result;
}

const headers = ['sku', 'name', 'quantity', 'warehouse', 'lastUpdated'];

return lines.map(line => {
  const values = parseCSVLine(line);
  const obj = {};
  headers.forEach((h, i) => { obj[h] = values[i] ?? ''; });
  obj.quantity = parseInt(obj.quantity, 10) || 0;
  return { json: obj };
});

For anything larger than about 10 MB, I switch to stream processing or use the Split In Batches node upstream. But for the daily vendor drops — the 2,000-row inventory updates and the lead lists — a small parser like this is more robust than fighting with node configuration options that don't expose the delimiter I need.

Generate Dynamic HTML and Slack Block Kit

I do not use the email node's built-in formatting. I do not use the Slack node's message composer. For any non-trivial message — a daily sales report, an alert with conditional sections, a formatted table — I build the payload in a Code node. Full control over layout, conditional rendering, and dynamic tables is worth the extra fifteen lines of code.

My most common use case is a daily sales summary that includes a red warning banner when revenue misses target:

const deals = $input.all().map(i => i.json);
const totalRevenue = deals.reduce((s, d) => s + (d.amount ?? 0), 0);
const target = 50000;
const hitTarget = totalRevenue >= target;

const tableRows = deals
  .sort((a, b) => (b.amount ?? 0) - (a.amount ?? 0))
  .slice(0, 10)
  .map(d => `
    <tr>
      <td style="padding:8px;border-bottom:1px solid #ddd;">${d.dealName}</td>
      <td style="padding:8px;border-bottom:1px solid #ddd;">${d.rep}</td>
      <td style="padding:8px;border-bottom:1px solid #ddd;text-align:right;">
        $${(d.amount ?? 0).toLocaleString()}
      </td>
      <td style="padding:8px;border-bottom:1px solid #ddd;">${d.stage}</td>
    </tr>
  `).join('');

const warningBanner = hitTarget ? '' : `
  <div style="background:#c0392b;color:#fff;padding:12px;margin:16px 0;
              border-radius:4px;text-align:center;">
    Revenue is $${(target - totalRevenue).toLocaleString()} below target.
  </div>
`;

const html = `
<div style="font-family:Arial,sans-serif;max-width:680px;margin:0 auto;">
  <h2 style="color:#2c3e50;">Daily Sales Report</h2>
  <p>${new Date().toISOString().slice(0, 10)}</p>
  ${warningBanner}
  <table style="width:100%;border-collapse:collapse;">
    <thead>
      <tr style="background:#34495e;color:#fff;">
        <th style="padding:8px;text-align:left;">Deal</th>
        <th style="padding:8px;text-align:left;">Rep</th>
        <th style="padding:8px;text-align:right;">Amount</th>
        <th style="padding:8px;text-align:left;">Stage</th>
      </tr>
    </thead>
    <tbody>${tableRows}</tbody>
  </table>
</div>`;

return [{
  json: {
    html,
    subject: `Sales Report - $${totalRevenue.toLocaleString()} ${hitTarget ? '(Target Met)' : '(Below Target)'}`
  }
}];

The downstream email node receives the html and subject fields. If I need to change the color of the warning banner or add a new column, I edit one Code node instead of hunting through three separate formatting nodes. The same pattern applies to Slack Block Kit JSON, PDF generation templates, and even dynamic SQL snippets.

Paginate APIs Without Native Nodes

The HTTP Request node has built-in pagination for simple offset-based APIs. The moment you encounter cursor-based pagination, non-standard header links, or a response shape where the next page URL is nested inside a pagination object, the built-in options fall short. This is where this.helpers.httpRequest inside a Code node becomes essential.

I use this pattern for custom REST APIs that lack dedicated n8n nodes. The loop fetches pages until the cursor is null or until a safety limit kicks in:

const baseUrl = 'https://api.example.com/v1/records';
const apiKey = $env.MY_API_KEY;
const allRecords = [];
let cursor = null;
let page = 0;
const maxPages = 50;

do {
  const url = cursor
    ? `${baseUrl}?limit=100&cursor=${encodeURIComponent(cursor)}`
    : `${baseUrl}?limit=100`;

  const response = await this.helpers.httpRequest({
    method: 'GET',
    url,
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
    },
  });

  if (response.data && Array.isArray(response.data)) {
    allRecords.push(...response.data);
  }

  cursor = response.pagination?.nextCursor ?? null;
  page++;

  if (cursor) {
    await new Promise(r => setTimeout(r, 200));
  }
} while (cursor && page < maxPages);

return allRecords.map(record => ({ json: record }));
Why maxPages is non-negotiable

APIs change, cursors break, and an infinite loop in a Code node will consume memory until the execution times out. The 200ms sleep between pages respects rate limits. this.helpers.httpRequest respects n8n's proxy settings and built-in retry logic — prefer it over importing axios or using raw fetch.

Deterministic Idempotency Keys

Key takeaway

If a workflow creates payments, sends transactional emails, or posts to public APIs, it needs idempotency. Without it, you double-charge customers, spam inboxes, or create duplicate records — and the failure mode is "it succeeds too many times," not "it errors."

n8n retries nodes. Webhook sources send duplicates. Humans click "Execute Workflow" twice. I use two complementary patterns. The first is for internal bookkeeping: a SHA-256 hash of the input data that I check against a processed_events table before acting. The second is for external API calls: a deterministic idempotency key passed in the request headers so the provider recognizes duplicates.

For external APIs like Stripe, I combine $execution.id with the item index and a business key:

const item = $input.item.json;
const itemIndex = $input.all().indexOf($input.item);

const idempotencyKey = `${$execution.id}_${itemIndex}_${item.orderId}`;

const response = await this.helpers.httpRequest({
  method: 'POST',
  url: 'https://api.stripe.com/v1/payment_intents',
  headers: {
    'Authorization': `Bearer ${item.stripeSecretKey}`,
    'Idempotency-Key': idempotencyKey,
    'Content-Type': 'application/x-www-form-urlencoded',
  },
  body: `amount=${item.amount}&currency=${item.currency}&customer=${item.customerId}`,
});

return [{
  json: {
    ...item,
    paymentIntentId: response.id,
    idempotencyKey,
  }
}];

This prevents duplicate charges during n8n's internal retries because the execution ID stays the same for a given run. The item index distinguishes items within that execution, and orderId ties it to the business event.

Caveat — manual reruns change `$execution.id`

If someone manually re-executes the workflow, a new payment intent will be created. For bulletproof protection against manual reruns, derive the key from business data only (orderId + invoiceDate) and store it in your own processed_events table with a unique constraint. Use the check-then-act pattern: query the table, skip if found, insert at the end.

const crypto = require('crypto');
const inputData = $input.item.json;

const internalKey = crypto
  .createHash('sha256')
  .update(JSON.stringify({
    orderId: inputData.orderId,
    updatedAt: inputData.updatedAt,
  }))
  .digest('hex');

return [{
  json: {
    ...inputData,
    _idempotency_key: internalKey,
  }
}];

Idempotency is not optional for production workflows. It is the single most important reliability pattern for any node that triggers side effects.

What to Do Monday Morning

You don't need to rewrite your entire workspace. Pick one workflow and apply a single pattern.

Collapse a Set-node chain into a Code node

Find a chain of three or more Set nodes doing the same transformation. Replace it with a Code node using optional chaining and nullish coalescing.

Replace a static-data accumulator with `$input.all()`

Find a batch operation where you used workflow static data to accumulate a total. Replace it with $input.all() and a reduce.

Add idempotency to one critical side-effecting node

Open your most critical payment or notification workflow and add an idempotency check before the side-effecting node.

Start a utility belt snippet library

Save your first three reusable helpers — a phone normaliser, an email validator, and a date parser. Copy them into the next workflow that needs them. On the third copy, promote to a sub-workflow.

The Code node is not an escape hatch. It is a precision instrument. Use it for the transformations that nodes cannot express, keep it small and readable, and let the canvas handle the orchestration.