2026-06-08
n8n Data Transformation Guide: JSON, Functions, and Mapping Like a Pro
Data transformation is where most n8n workflows succeed or fail. Master the Function node, JSON parsing, data mapping, and the Item Lists node — with 10 real-world transformation recipes.
n8n Data Transformation Guide: JSON, Functions, and Mapping Like a Pro
The hardest part of building n8n workflows isn't connecting APIs — it's transforming data between them. Every service speaks a slightly different JSON dialect, and if you can't reshape data on the fly, your automation breaks.
This guide covers the n8n data transformation toolkit: the Function node, JSON parsing, data mapping, and the Item Lists node. Each section includes a real-world recipe.
The Data Transformation Stack in n8n
| Node | Best For | Complexity | |------|----------|------------| | Set | Simple field mapping, adding/removing fields | Low | | Function | Custom JavaScript transforms, complex logic | Medium-High | | Item Lists | Splitting, aggregating, sorting arrays | Medium | | Code | Python transforms (self-hosted only) | High | | Merge | Combining data from multiple branches | Low-Medium | | Split In Batches | Processing large datasets in chunks | Low |
Recipe 1: Normalize Inconsistent Webhook Payloads
Problem: Two different form tools send webhook data in different shapes. Tool A sends {name: "John", email: "john@test.com"}. Tool B sends {contact: {full_name: "John", email_address: "john@test.com"}}.
Solution — Function Node:
// Normalize any input to: { name, email }
const input = $input.all()[0].json;
const normalized = {
name: input.name || input.contact?.full_name || input.fullName || "Unknown",
email: input.email || input.contact?.email_address || input.emailAddress || "",
source: input.source || "unknown",
received_at: new Date().toISOString()
};
return [{ json: normalized }];
Why this matters: Your downstream nodes (CRM, email, database) expect name and email. Normalize at the entry point and everything else stays clean.
Recipe 2: Enrich Data by Merging API Responses
Problem: A webhook gives you a company domain (acme.com). You need the full company profile from Clearbit or a similar enrichment API.
Solution — HTTP Request + Function merge:
- HTTP Request node calls
https://company.clearbit.com/v2/companies/find?domain={{domain}} - Function node merges:
const lead = $input.all()[0].json; // original webhook data
const enriched = $input.all()[1].json; // API enrichment response
return [{
json: {
...lead,
company_name: enriched.name,
company_size: enriched.metrics?.employees,
company_industry: enriched.category?.industry,
company_logo: enriched.logo,
enrichment_timestamp: new Date().toISOString()
}
}];
Key concept: The Function node can access data from multiple input branches using $input.all()[index].
Recipe 3: Transform Array of Objects with Filter + Map
Problem: You have 100 Shopify orders. You only need orders > $50 that contain products from a specific vendor, and you want a simplified output.
const orders = $input.all()[0].json.orders;
const targetVendor = "FlowForge";
const filtered = orders
.filter(order => parseFloat(order.total_price) > 50)
.filter(order =>
order.line_items?.some(item => item.vendor === targetVendor)
)
.map(order => ({
order_id: order.id,
customer: `${order.customer.first_name} ${order.customer.last_name}`,
email: order.customer.email,
total: order.total_price,
products: order.line_items.map(i => i.title).join(", "),
created_at: order.created_at
}));
// Return as individual items for downstream processing
return filtered.map(item => ({ json: item }));
Note: Returning an array of {json: ...} objects tells n8n to treat each as a separate item, enabling parallel processing downstream.
Recipe 4: Aggregate Daily Metrics from Streaming Data
Problem: A webhook fires on every new signup throughout the day. You want a single daily summary email, not 50 individual emails.
Solution — Webhook → Database (or Wait node) → Cron-triggered aggregation:
// This Function node processes ALL items collected during the day
const allSignups = $input.all();
const summary = {
total_signups: allSignups.length,
date: new Date().toISOString().split('T')[0],
by_source: {},
by_plan: {},
emails: []
};
allSignups.forEach(item => {
const { source, plan, email } = item.json;
summary.by_source[source] = (summary.by_source[source] || 0) + 1;
summary.by_plan[plan] = (summary.by_plan[plan] || 0) + 1;
summary.emails.push(email);
});
return [{ json: summary }];
Recipe 5: Handle Paginated API Responses
Problem: An API returns results in pages (50 per page). You need all records.
n8n handles this natively with the HTTP Request node's pagination settings, but sometimes you need custom logic:
// Inside a Function node, after collecting all pages
const allPages = $input.all(); // Each item = one page of results
const allRecords = [];
allPages.forEach(page => {
const records = page.json.data || page.json.results || page.json.items || [];
allRecords.push(...records);
});
// Deduplicate by ID
const unique = [];
const seen = new Set();
allRecords.forEach(record => {
if (!seen.has(record.id)) {
seen.add(record.id);
unique.push(record);
}
});
return unique.map(record => ({ json: record }));
Recipe 6: Conditional Field Mapping Based on Data Values
Problem: If the lead came from LinkedIn, send to one CRM pipeline. If from the website, send to another. Fields differ by source.
const lead = $input.all()[0].json;
const source = lead.source || lead.utm_source || "direct";
let crmPayload;
if (source === "linkedin") {
crmPayload = {
pipeline: "outbound",
contact: {
name: lead.profile?.name,
title: lead.profile?.headline,
company: lead.profile?.company
}
};
} else if (source === "website") {
crmPayload = {
pipeline: "inbound",
contact: {
name: lead.name || lead.form_name,
email: lead.email,
page: lead.landing_page
}
};
} else {
crmPayload = {
pipeline: "other",
contact: { name: lead.name, email: lead.email }
};
}
return [{ json: { ...crmPayload, source, processed_at: new Date().toISOString() } }];
Recipe 7: Date/Time Transformations for Scheduling
n8n's built-in date handling can trip you up. Here's a Swiss Army knife function:
const now = new Date();
// Common date transforms
const transforms = {
iso: now.toISOString(),
unix: Math.floor(now.getTime() / 1000),
date_only: now.toISOString().split('T')[0],
time_only: now.toTimeString().split(' ')[0],
// Next Monday at 9 AM:
next_monday: (() => {
const d = new Date(now);
d.setDate(d.getDate() + ((1 + 7 - d.getDay()) % 7 || 7));
d.setHours(9, 0, 0, 0);
return d.toISOString();
})(),
// 30 days from now:
plus_30_days: new Date(now.getTime() + 30 * 86400000).toISOString(),
// Start of current month:
month_start: new Date(now.getFullYear(), now.getMonth(), 1).toISOString(),
// Human readable:
human: now.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})
};
return [{ json: transforms }];
Recipe 8: Error-Resilient JSON Parsing
Problem: A webhook sometimes sends malformed JSON, or a field is sometimes a string and sometimes an object.
function safeJSONParse(input) {
if (typeof input === 'object') return input;
try {
return JSON.parse(input);
} catch {
return { _parse_error: true, raw: String(input).substring(0, 200) };
}
}
const raw = $input.all()[0].json;
const parsed = safeJSONParse(raw.payload || raw.data || raw);
return [{ json: parsed }];
Recipe 9: Extract Nested Fields Deep in JSON
Problem: You need response.data.user.profile.settings.notifications.email but the path might not exist.
function deepGet(obj, path, fallback = null) {
const keys = path.split('.');
let current = obj;
for (const key of keys) {
if (current == null || typeof current !== 'object') return fallback;
current = current[key];
}
return current ?? fallback;
}
const data = $input.all()[0].json;
return [{
json: {
email_notifications: deepGet(data, 'user.profile.settings.notifications.email', false),
user_name: deepGet(data, 'user.profile.name', 'Unknown'),
account_status: deepGet(data, 'user.account.status', 'inactive')
}
}];
Recipe 10: Build a CSV Export from JSON Data
const items = $input.all();
if (items.length === 0) {
return [{ json: { csv: '', error: 'No data to export' } }];
}
// Get headers from first item
const headers = Object.keys(items[0].json);
// Build CSV rows
const csvRows = [headers.join(',')];
items.forEach(item => {
const row = headers.map(h => {
const val = item.json[h];
// Escape quotes and wrap in quotes if contains comma or quote
const str = val == null ? '' : String(val);
return str.includes(',') || str.includes('"')
? `"${str.replace(/"/g, '""')}"`
: str;
});
csvRows.push(row.join(','));
});
const csv = csvRows.join('\n');
return [{ json: { csv, row_count: items.length, generated_at: new Date().toISOString() } }];
The Most Common Transformation Mistake
Assuming $input.all() has data. Always check:
if ($input.all().length === 0) {
return [{ json: { status: 'skipped', reason: 'No input data' } }];
}
n8n doesn't throw an error when upstream nodes return nothing — it passes an empty array to the next node. Check explicitly or your Function node will produce confusing results.
When to Use the Set Node Instead of Function
The Set node is faster to configure and less error-prone for simple operations:
| Operation | Use Set Node | Use Function Node | |-----------|-------------|-------------------| | Rename one field | ✅ | Overkill | | Add a static field (e.g., "source": "web") | ✅ | Overkill | | Conditional field mapping | ❌ | ✅ | | Array .filter()/.map() | ❌ | ✅ | | Complex calculations | ❌ | ✅ | | Type conversions | ❌ | ✅ |
Testing Data Transformations
- Use the n8n "Listen to Webhook" test mode — send real payloads and inspect each node's output
- Console.log everything — the Function node's
console.log()output appears in n8n's execution log - Add a "Debug" Set node temporarily — set fields to see intermediate values without modifying your Function code
- Test edge cases: Empty arrays, null values, missing fields, very large datasets
Key Takeaways
- Normalize early: Transform data at the entry point of your workflow and everything downstream stays consistent
- Use Set for simple, Function for complex: Don't reach for JavaScript when a Set node does the job
- Check
$input.all()length: Empty inputs are a leading cause of silent workflow failures - Defensive parsing wins: Always assume external APIs might return unexpected shapes
Master these transformations and you'll spend less time debugging and more time building automations that actually deliver value.
Ready to automate?
Browse 25+ production-ready n8n templates. Import, configure, and run — all in under 10 minutes.
Browse Templates