Map/Reduce Script Development
Map/Reduce scripts process large volumes of data by breaking work into smaller chunks that run in parallel, then combining results.
When to Use Map/Reduce
| Use Case | Example |
|---|---|
| Mass updates | Update all customer records |
| Data migration | Import thousands of records |
| Complex calculations | Recalculate pricing across catalog |
| Report generation | Aggregate data from many sources |
| Batch processing | Process large file imports |
Map/Reduce vs Scheduled Script
| Criteria | Map/Reduce | Scheduled Script |
|---|---|---|
| Data volume | 10,000+ records | < 10,000 records |
| Parallelism | Built-in | Manual |
| Governance | Per stage | 10,000 units total |
| Complexity | Higher | Lower |
| Resumability | Automatic | Manual |
Map/Reduce Execution Flow
┌─────────────────────────────────────────────────────────────────────────────┐
│ MAP/REDUCE EXECUTION FLOW │
└─────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ 1. getInputData() │
│ ────────────────────────────────────────────────────────────────│
│ • Runs ONCE at start │
│ • Returns data to process │
│ • Can return: Search, Array, or Object │
│ • Governance: 10,000 units │
└──────────────────────────────┬───────────────────────────────────┘
│
Returns search or array of items
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ 2. map(context) │
│ ────────────────────────────────────────────────────────────────│
│ • Runs for EACH item from getInputData │
│ • Runs in PARALLEL (multiple at once) │
│ • Transform or filter data │
│ • Write key-value pairs for reduce │
│ • Governance: 1,000 units per invocation │
└──────────────────────────────┬───────────────────────────────────┘
│
┌───────────────────────────┼───────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ map(1) │ │ map(2) │ │ map(3) │
│ Item 1 │ │ Item 2 │ │ Item 3 │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
│ context.write() │ context.write() │ context.write()
│ key: "A" │ key: "A" │ key: "B"
│ value: data1 │ value: data2 │ value: data3
│ │ │
└──────────────────────────┼──────────────────────────┘
│
Keys grouped together
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ 3. reduce(context) │
│ ────────────────────────────────────────────────────────────────│
│ • Runs for EACH UNIQUE KEY │
│ • Receives all values for that key │
│ • Aggregate, summarize, or process │
│ • Write results for summarize │
│ • Governance: 5,000 units per invocation │
└──────────────────────────────┬───────────────────────────────────┘
│
┌───────────────────────────┴───────────────────────────┐
│ │
▼ ▼
┌───────────────────────────┐ ┌───────────────────────────┐
│ reduce(key="A") │ │ reduce(key="B") │
│ values: [data1, data2] │ │ values: [data3] │
└─────────────┬─────────────┘ └─────────────┬─────────────┘
│ │
│ context.write() │ context.write()
│ key: "A" │ key: "B"
│ value: result_A │ value: result_B
│ │
└─────────────────────┬─────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ 4. summarize(context) │
│ ────────────────────────────────────────────────────────────────│
│ • Runs ONCE at end │
│ • Access all outputs from reduce │
│ • Send summary email │
│ • Log completion │
│ • Governance: 10,000 units │
└──────────────────────────────────────────────────────────────────┘
Stage Data Flow
┌─────────────────────────────────────────────────────────────────────────────┐
│ DATA FLOW BETWEEN STAGES │
└─────────────────────────────────────────────────────────────────────────────┘
getInputData()
│
│ Returns: Search OR Array OR Object
│
│ Example Search Return:
│ ┌────────────────────────────────────────────┐
│ │ search.create({ │
│ │ type: 'salesorder', │
│ │ filters: [...], │
│ │ columns: [...] │
│ │ }) │
│ └────────────────────────────────────────────┘
│
▼
map(context)
│
│ context.key = Internal ID (from search) or index (from array)
│ context.value = Search result or array element (as JSON string)
│
│ Parse the value:
│ ┌────────────────────────────────────────────┐
│ │ const data = JSON.parse(context.value); │
│ └────────────────────────────────────────────┘
│
│ Write to reduce:
│ ┌────────────────────────────────────────────┐
│ │ context.write({ │
│ │ key: 'category_A', ◄── Group key │
│ │ value: processedData ◄── Any data │
│ │ }); │
│ └────────────────────────────────────────────┘
│
▼
reduce(context)
│
│ context.key = The key you wrote in map
│ context.values = Array of ALL values for this key
│
│ ┌────────────────────────────────────────────┐
│ │ context.values.forEach(value => { │
│ │ const data = JSON.parse(value); │
│ │ // Process... │
│ │ }); │
│ └────────────────────────────────────────────┘
│
│ Write to summarize:
│ ┌────────────────────────────────────────────┐
│ │ context.write({ │
│ │ key: context.key, │
│ │ value: aggregatedResult │
│ │ }); │
│ └────────────────────────────────────────────┘
│
▼
summarize(context)
│
│ Access outputs from reduce:
│ ┌────────────────────────────────────────────┐
│ │ context.output.iterator().each( │
│ │ (key, value) => { │
│ │ // Process each reduce output │
│ │ return true; │
│ │ } │
│ │ ); │
│ └────────────────────────────────────────────┘
│
▼
COMPLETE
Basic Map/Reduce Structure
/**
* @NApiVersion 2.1
* @NScriptType MapReduceScript
* @NModuleScope SameAccount
*/
define(['N/search', 'N/record', 'N/runtime', 'N/log'],
(search, record, runtime, log) => {
/**
* Get input data to process
* @returns {search.Search|Array|Object}
*/
const getInputData = () => {
log.audit('Stage', 'getInputData started');
// Return a search - each result becomes a map() call
return search.create({
type: search.Type.SALES_ORDER,
filters: [
['mainline', 'is', 'T'],
'AND',
['status', 'anyof', 'SalesOrd:B']
],
columns: ['tranid', 'entity', 'total']
});
};
/**
* Process each input record
* @param {Object} context
* @param {string} context.key - Record ID
* @param {string} context.value - Record data (JSON string)
*/
const map = (context) => {
const searchResult = JSON.parse(context.value);
log.debug('Map', `Processing order ID: ${context.key}`);
// Do processing
const processed = {
id: context.key,
tranId: searchResult.values.tranid,
amount: parseFloat(searchResult.values.total) || 0
};
// Group by customer
const customerId = searchResult.values.entity.value;
context.write({
key: customerId,
value: processed
});
};
/**
* Process grouped data
* @param {Object} context
* @param {string} context.key - Group key
* @param {Array} context.values - All values for this key
*/
const reduce = (context) => {
log.debug('Reduce', `Processing customer: ${context.key}`);
let totalAmount = 0;
let orderCount = 0;
context.values.forEach(value => {
const data = JSON.parse(value);
totalAmount += data.amount;
orderCount++;
});
// Write summary for this customer
context.write({
key: context.key,
value: JSON.stringify({
customerId: context.key,
orderCount: orderCount,
totalAmount: totalAmount
})
});
};
/**
* Final summary
* @param {Object} context
* @param {Object} context.inputSummary
* @param {Object} context.mapSummary
* @param {Object} context.reduceSummary
* @param {Iterator} context.output
*/
const summarize = (context) => {
log.audit('Stage', 'summarize started');
// Check for errors
handleErrors(context);
// Process outputs from reduce
let totalCustomers = 0;
let grandTotal = 0;
context.output.iterator().each((key, value) => {
const data = JSON.parse(value);
totalCustomers++;
grandTotal += data.totalAmount;
log.debug('Customer Summary', data);
return true;
});
log.audit('Complete', `Processed ${totalCustomers} customers, total: $${grandTotal}`);
};
/**
* Handle errors from all stages
*/
const handleErrors = (context) => {
// Input stage errors
if (context.inputSummary.error) {
log.error('Input Error', context.inputSummary.error);
}
// Map stage errors
context.mapSummary.errors.iterator().each((key, error) => {
log.error('Map Error', `Key: ${key}, Error: ${error}`);
return true;
});
// Reduce stage errors
context.reduceSummary.errors.iterator().each((key, error) => {
log.error('Reduce Error', `Key: ${key}, Error: ${error}`);
return true;
});
};
return {
getInputData,
map,
reduce,
summarize
};
});
Return Types for getInputData
Return a Search
const getInputData = () => {
// Each search result becomes one map() invocation
return search.create({
type: search.Type.CUSTOMER,
filters: [['isinactive', 'is', 'F']],
columns: ['companyname', 'email', 'salesrep']
});
};
Return an Array
const getInputData = () => {
// Each array element becomes one map() invocation
return [
{ id: 1, name: 'Item A', price: 100 },
{ id: 2, name: 'Item B', price: 200 },
{ id: 3, name: 'Item C', price: 300 }
];
};
Return an Object
const getInputData = () => {
// Each key-value pair becomes one map() invocation
// context.key = the object key
// context.value = the value
return {
'customer_1': { name: 'Acme Corp', amount: 1000 },
'customer_2': { name: 'XYZ Inc', amount: 2000 },
'customer_3': { name: 'ABC Ltd', amount: 3000 }
};
};
Return Search ID
const getInputData = () => {
const script = runtime.getCurrentScript();
const searchId = script.getParameter({
name: 'custscript_search_id'
});
// Load existing saved search
return search.load({ id: searchId });
};
Complete Example: Mass Price Update
/**
* @NApiVersion 2.1
* @NScriptType MapReduceScript
* @NModuleScope SameAccount
* @description Updates item prices based on cost plus markup
*/
define(['N/search', 'N/record', 'N/runtime', 'N/email', 'N/log'],
(search, record, runtime, email, log) => {
/**
* Get all inventory items to update
*/
const getInputData = () => {
log.audit('Price Update', 'Starting getInputData');
const script = runtime.getCurrentScript();
const markupPercent = script.getParameter({
name: 'custscript_markup_percent'
}) || 30;
log.debug('Parameters', `Markup: ${markupPercent}%`);
return search.create({
type: search.Type.INVENTORY_ITEM,
filters: [
['isinactive', 'is', 'F'],
'AND',
['type', 'anyof', 'InvtPart']
],
columns: [
'itemid',
'displayname',
'cost',
'baseprice',
'custitem_category'
]
});
};
/**
* Process each item - calculate new price
*/
const map = (context) => {
try {
const searchResult = JSON.parse(context.value);
const itemId = context.key;
const values = searchResult.values;
const cost = parseFloat(values.cost) || 0;
const currentPrice = parseFloat(values.baseprice) || 0;
const category = values.custitem_category?.value || 'uncategorized';
// Get markup from parameters
const script = runtime.getCurrentScript();
const markupPercent = script.getParameter({
name: 'custscript_markup_percent'
}) || 30;
// Calculate new price
const newPrice = cost * (1 + markupPercent / 100);
// Only update if price changed significantly (>1%)
const priceChange = Math.abs(newPrice - currentPrice) / (currentPrice || 1);
if (priceChange > 0.01 && cost > 0) {
context.write({
key: category,
value: JSON.stringify({
itemId: itemId,
itemName: values.itemid,
cost: cost,
oldPrice: currentPrice,
newPrice: newPrice,
change: ((newPrice - currentPrice) / (currentPrice || 1) * 100).toFixed(2)
})
});
}
} catch (e) {
log.error('Map Error', `Item ${context.key}: ${e.message}`);
}
};
/**
* Update items by category
*/
const reduce = (context) => {
const category = context.key;
let updatedCount = 0;
let errorCount = 0;
const updates = [];
log.debug('Reduce', `Processing category: ${category}`);
context.values.forEach(value => {
try {
const item = JSON.parse(value);
// Update the item
record.submitFields({
type: record.Type.INVENTORY_ITEM,
id: item.itemId,
values: {
'baseprice': item.newPrice
}
});
updates.push({
itemName: item.itemName,
oldPrice: item.oldPrice,
newPrice: item.newPrice,
change: item.change
});
updatedCount++;
} catch (e) {
log.error('Update Error', `Item ${item?.itemId}: ${e.message}`);
errorCount++;
}
});
// Write summary for this category
context.write({
key: category,
value: JSON.stringify({
category: category,
updatedCount: updatedCount,
errorCount: errorCount,
updates: updates
})
});
};
/**
* Send summary email
*/
const summarize = (context) => {
log.audit('Price Update', 'Summarize started');
// Handle errors
logErrors(context);
// Gather results
const results = [];
let totalUpdated = 0;
let totalErrors = 0;
context.output.iterator().each((key, value) => {
const data = JSON.parse(value);
results.push(data);
totalUpdated += data.updatedCount;
totalErrors += data.errorCount;
return true;
});
// Build email body
const emailBody = buildEmailBody(results, totalUpdated, totalErrors);
// Send notification
const script = runtime.getCurrentScript();
const recipientId = script.getParameter({
name: 'custscript_notify_user'
});
if (recipientId) {
email.send({
author: runtime.getCurrentUser().id,
recipients: [recipientId],
subject: `Price Update Complete - ${totalUpdated} Items Updated`,
body: emailBody
});
log.audit('Email Sent', `Notification sent to ${recipientId}`);
}
// Log summary
log.audit('Price Update Complete', {
totalUpdated: totalUpdated,
totalErrors: totalErrors,
categories: results.length
});
};
/**
* Log all errors from each stage
*/
const logErrors = (context) => {
// Input errors
if (context.inputSummary.error) {
log.error('Input Stage Error', context.inputSummary.error);
}
// Map errors
context.mapSummary.errors.iterator().each((key, error) => {
log.error('Map Stage Error', { key: key, error: error });
return true;
});
// Reduce errors
context.reduceSummary.errors.iterator().each((key, error) => {
log.error('Reduce Stage Error', { key: key, error: error });
return true;
});
};
/**
* Build HTML email body
*/
const buildEmailBody = (results, totalUpdated, totalErrors) => {
let html = `
<html>
<head>
<style>
body { font-family: Arial, sans-serif; }
h2 { color: #333; }
table { border-collapse: collapse; width: 100%; margin: 20px 0; }
th { background-color: #4CAF50; color: white; padding: 10px; }
td { padding: 8px; border: 1px solid #ddd; }
.summary { background: #f5f5f5; padding: 15px; border-radius: 5px; }
.error { color: #d32f2f; }
.success { color: #388e3c; }
</style>
</head>
<body>
<h2>Price Update Summary</h2>
<div class="summary">
<p><strong>Total Items Updated:</strong> <span class="success">${totalUpdated}</span></p>
<p><strong>Total Errors:</strong> <span class="error">${totalErrors}</span></p>
<p><strong>Categories Processed:</strong> ${results.length}</p>
</div>
<h3>Updates by Category</h3>
<table>
<tr>
<th>Category</th>
<th>Updated</th>
<th>Errors</th>
</tr>
`;
results.forEach(result => {
html += `
<tr>
<td>${result.category}</td>
<td>${result.updatedCount}</td>
<td class="error">${result.errorCount}</td>
</tr>
`;
});
html += `
</table>
<h3>Detailed Changes</h3>
`;
results.forEach(result => {
if (result.updates.length > 0) {
html += `
<h4>${result.category}</h4>
<table>
<tr>
<th>Item</th>
<th>Old Price</th>
<th>New Price</th>
<th>Change</th>
</tr>
`;
result.updates.slice(0, 20).forEach(update => {
html += `
<tr>
<td>${update.itemName}</td>
<td>$${parseFloat(update.oldPrice).toFixed(2)}</td>
<td>$${parseFloat(update.newPrice).toFixed(2)}</td>
<td>${update.change}%</td>
</tr>
`;
});
if (result.updates.length > 20) {
html += `<tr><td colspan="4">... and ${result.updates.length - 20} more</td></tr>`;
}
html += '</table>';
}
});
html += `
<p style="color: #666; font-size: 12px; margin-top: 30px;">
This is an automated message from NetSuite.
</p>
</body>
</html>
`;
return html;
};
return {
getInputData,
map,
reduce,
summarize
};
});
Script Deployment XML
<?xml version="1.0" encoding="UTF-8"?>
<mapreducescript scriptid="customscript_price_update_mr">
<name>Mass Price Update</name>
<scriptfile>[/SuiteScripts/MapReduce/price_update_mr.js]</scriptfile>
<description>Updates item prices based on cost plus markup</description>
<isinactive>F</isinactive>
<notifyowner>T</notifyowner>
<scriptcustomfields>
<scriptcustomfield scriptid="custscript_markup_percent">
<label>Markup Percentage</label>
<fieldtype>PERCENT</fieldtype>
<defaultvalue>30</defaultvalue>
<ismandatory>T</ismandatory>
</scriptcustomfield>
<scriptcustomfield scriptid="custscript_notify_user">
<label>Notification Recipient</label>
<fieldtype>SELECT</fieldtype>
<selectrecordtype>-4</selectrecordtype>
</scriptcustomfield>
<scriptcustomfield scriptid="custscript_search_id">
<label>Item Search ID</label>
<fieldtype>SELECT</fieldtype>
<selectrecordtype>-119</selectrecordtype>
<description>Optional: Use specific saved search</description>
</scriptcustomfield>
</scriptcustomfields>
<scriptdeployments>
<scriptdeployment scriptid="customdeploy_price_update_mr">
<status>RELEASED</status>
<title>Price Update - Manual Run</title>
<isdeployed>T</isdeployed>
<loglevel>DEBUG</loglevel>
<allroles>F</allroles>
<audslctrole>
<role>[Administrator]</role>
</audslctrole>
</scriptdeployment>
</scriptdeployments>
</mapreducescript>
Skipping Stages
You don't need all four stages. Common patterns:
Map Only (No Grouping Needed)
const getInputData = () => {
return search.create({ /* ... */ });
};
const map = (context) => {
// Process each record directly
const data = JSON.parse(context.value);
updateRecord(data);
// Write directly to output (skip reduce)
context.write({
key: context.key,
value: 'processed'
});
};
// No reduce function
const summarize = (context) => {
let count = 0;
context.output.iterator().each(() => {
count++;
return true;
});
log.audit('Complete', `Processed ${count} records`);
};
return { getInputData, map, summarize };
Map and Reduce (Most Common)
return {
getInputData,
map,
reduce,
summarize
};
Governance Per Stage
┌─────────────────────────────────────────────────────────────────────────────┐
│ GOVERNANCE LIMITS BY STAGE │
└─────────────────────────────────────────────────────────────────────────────┘
┌────────────────────────┬─────────────────┬──────────────────────────────────┐
│ Stage │ Units/Run │ Notes │
├────────────────────────┼─────────────────┼──────────────────────────────────┤
│ getInputData │ 10,000 │ Runs once, defines all work │
├────────────────────────┼─────────────────┼──────────────────────────────────┤
│ map │ 1,000 │ Per invocation (parallel) │
│ │ │ Auto-retries on failure │
├────────────────────────┼─────────────────┼──────────────────────────────────┤
│ reduce │ 5,000 │ Per unique key │
│ │ │ More units for aggregation │
├────────────────────────┼─────────────────┼──────────────────────────────────┤
│ summarize │ 10,000 │ Runs once at end │
│ │ │ For final reporting │
└────────────────────────┴─────────────────┴──────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ TOTAL SCRIPT EXECUTION │
│ ────────────────────────────────────────────────────────────────│
│ No total limit - stages have individual limits │
│ Script runs until all data processed │
│ Automatic retry on transient failures │
└──────────────────────────────────────────────────────────────────┘
Error Handling
In Map Stage
const map = (context) => {
try {
const data = JSON.parse(context.value);
processData(data);
context.write({
key: 'success',
value: context.key
});
} catch (e) {
// Log error - script continues with next item
log.error('Map Error', {
key: context.key,
error: e.message
});
// Optionally write to error bucket
context.write({
key: 'error',
value: JSON.stringify({
id: context.key,
error: e.message
})
});
}
};
In Summarize Stage
const summarize = (context) => {
// Check input stage
if (context.inputSummary.error) {
log.error('Input Failed', context.inputSummary.error);
sendErrorNotification('getInputData failed: ' + context.inputSummary.error);
return;
}
// Check map stage
let mapErrors = 0;
context.mapSummary.errors.iterator().each((key, error) => {
mapErrors++;
log.error('Map Error', `${key}: ${error}`);
return true;
});
// Check reduce stage
let reduceErrors = 0;
context.reduceSummary.errors.iterator().each((key, error) => {
reduceErrors++;
log.error('Reduce Error', `${key}: ${error}`);
return true;
});
if (mapErrors > 0 || reduceErrors > 0) {
sendErrorNotification(`Errors: Map=${mapErrors}, Reduce=${reduceErrors}`);
}
};
Triggering Map/Reduce from Code
const task = require('N/task');
const triggerMapReduce = () => {
const mrTask = task.create({
taskType: task.TaskType.MAP_REDUCE,
scriptId: 'customscript_price_update_mr',
deploymentId: 'customdeploy_price_update_mr',
params: {
'custscript_markup_percent': 25
}
});
const taskId = mrTask.submit();
log.audit('Map/Reduce Started', `Task ID: ${taskId}`);
return taskId;
};
// Check status
const checkStatus = (taskId) => {
const taskStatus = task.checkStatus({
taskId: taskId
});
log.debug('Task Status', {
status: taskStatus.status,
stage: taskStatus.stage,
percentComplete: taskStatus.getPercentageCompleted()
});
return taskStatus;
};
Best Practices
| Practice | Description |
|---|---|
| Keep map lightweight | Do heavy processing in reduce |
| Use appropriate keys | Group logically for reduce |
| Handle errors gracefully | Log errors, continue processing |
| Check governance | Monitor in summarize stage |
| Parse JSON carefully | Always wrap in try/catch |
| Limit reduce work | Avoid too many values per key |
Common Patterns
Progress Tracking
let processedInMap = 0;
const map = (context) => {
processedInMap++;
if (processedInMap % 100 === 0) {
log.audit('Progress', `Processed ${processedInMap} in map`);
}
// Process...
};
Conditional Processing
const map = (context) => {
const data = JSON.parse(context.value);
// Skip based on condition
if (data.values.isinactive === 'T') {
return; // Don't write - skip this record
}
// Process active records
context.write({
key: data.values.category,
value: data
});
};
Next Steps
- RESTlet - Create REST APIs
- Workflow Action - Workflow script actions
- Data Migration Scenario - Real-world example