SuiteScript 1.0 to 2.1 Migration: Errors and API Changes
SuiteScript 1.0 to 2.1 Migration: Errors and API Changes
The usual advice for SuiteScript 1.0-to-2.1 migrations is "read the documentation." That is not wrong, but it misses the practical problem: the documentation describes the 2.1 API. It does not tell you how to take a specific 1.0 script and get to a working 2.1 equivalent without breaking production behaviour.
This post is the translation guide I wish existed when I first started doing these migrations. Actual diffs, real failure modes, and the errors that tend to survive code review and only show up in testing.
Migration is one stage of a larger modernisation effort. The SuiteScript modernisation guide gives the overview; this post is the detail on the migration itself. It is also worth knowing that 2.1 is the floor for NetSuite's newer capabilities: the N/llm module and the Custom Tool script type for AI work are 2.1 only, so the migration buys capability as well as stability.
Assess complexity before you start
Before touching a 1.0 script, score its migration complexity. Oracle's SuiteScript upgrade tooling uses a seven-factor model to estimate effort. Apply it to every script in scope before committing to a timeline:
| Factor | Low (1 pt) | Medium (2 pts) | High (3 pts) |
|---|---|---|---|
| Lines of code | Under 100 | 100 to 500 | 500+ |
Unique nlapi* calls | Fewer than 10 | 10 to 30 | 30+ |
| Subrecord usage | None | Read-only | Create or edit |
| Date/time with timezone | None | Body fields | Sublist date fields |
| Recovery point pattern | None | nlapiSetRecoveryPoint | Recovery and yield combined |
| Custom module includes | None | 1 to 2 | 3+ |
| Sublist operations | None | Read-only | Dynamic line manipulation |
A score of 7 to 10 is a straightforward conversion, typically one to two hours. 11 to 15 needs careful testing and some architectural decisions. 16 to 21 calls for a staged conversion with a full test suite against sandbox data.
The high-risk factors are subrecord usage, the recovery point pattern, and dynamic sublist operations. These require redesign rather than translation, and none of them has a direct function-to-function mapping in 2.1.
Before you start writing 2.1 code, run this checklist on the script:
- Search for variable names
logorutilanywhere in the file. These are reserved globals in 2.1 and shadow silently (see below). - Look for any
nlapiSetRecoveryPoint()ornlapiYieldScript()calls. Both are removed in 2.1 and need to be redesigned as Map/Reduce. - Look for any
nlapiSendFax()calls. Fax functionality was not carried forward to 2.1. - Find every sublist loop. They all need 0-based indexing.
- Find every
catchblock.e instanceof nlobjErrorande.getCode()need rewriting.
Need to know what a 1.0 to 2.1 migration would involve?
Bring one representative script or a list of scripts. I'll help you assess migration complexity, risk, and the sensible order of work.
Book a migration reviewNot ready to book? Send the symptom and I'll tell you whether it sounds like work I can help with.
The module system change is not optional
In SuiteScript 1.0, there is no module system. Your script is a JavaScript file. NetSuite injects the nlapi global namespace and your functions are available by name. For a User Event script, you define function beforeLoad(type, form, request) and NetSuite finds it.
In SuiteScript 2.1, every file must use AMD module syntax. All N/ modules must be explicitly declared as dependencies. The file must include annotation headers. Without these, the file is not a valid 2.1 script and NetSuite will refuse to save it as a script record.
The minimum valid 2.1 User Event script structure:
/**
* @NApiVersion 2.1
* @NScriptType UserEventScript
* @NModuleScope SameAccount
*/
define(['N/record', 'N/log'], (record, log) => {
const beforeLoad = (context) => {
// your logic here
};
const beforeSubmit = (context) => {
// your logic here
};
const afterSubmit = (context) => {
// your logic here
};
return { beforeLoad, beforeSubmit, afterSubmit };
});Three things to note about the annotations:
- Use
@NApiVersion 2.1, not2.0. It adds support for modern JavaScript syntax (template literals, destructuring,const/let, arrow functions,async/await) and theN/querymodule. @NScriptTypemust exactly match the script type as NetSuite recognises it. Common values:UserEventScript,ClientScript,ScheduledScript,Restlet,MapReduceScript,Suitelet,MassUpdateScript,Portlet,WorkflowActionScript. Other valid types includeCustomTransaction,BundleInstallation, andCustomTool.- Use
@NModuleScope SameAccountfor all custom scripts.PublicandProvisionedare for SuiteApp development.
Function signatures change for every script type
In SuiteScript 1.0, event handler functions received positional parameters:
// 1.0 User Event
function beforeSubmit(type) {
// type is a string: 'create', 'edit', 'delete', etc.
var entity = nlapiGetFieldValue('entity');
}In 2.1, event handlers receive a single context object. The record is accessed through context.newRecord (for the new state) and context.oldRecord (for the pre-save state in beforeSubmit and afterSubmit):
// 2.1 User Event
const beforeSubmit = (context) => {
const type = context.type; // context.UserEventType.EDIT, etc.
const entity = context.newRecord.getValue({ fieldId: 'entity' });
};For Client Scripts, the event names are the same but the parameters change similarly:
// 1.0 Client Script
function pageInit(type) {
nlapiSetFieldValue('custbody_status', 'pending');
}
function fieldChanged(type, name) {
if (name === 'entity') {
// do something
}
}// 2.1 Client Script
const pageInit = (context) => {
context.currentRecord.setValue({ fieldId: 'custbody_status', value: 'pending' });
};
const fieldChanged = (context) => {
if (context.fieldId === 'entity') {
// do something
}
};The context.currentRecord object in 2.1 client scripts is a live reference to the form record. It is not the same as a server-side record.load(): it is a wrapper around the fields currently rendered in the form. You cannot call context.currentRecord.save() from a Client Script event. You work with the record in memory and NetSuite handles the save.
SuiteScript 2.1 entry point scripts must implement one script type function
This is the error you will see if NetSuite cannot find the expected entry point function in the return object of your define() call. The exact wording is usually some version of "entry point scripts must implement one script type function." It is one of the more confusing migration errors because the file can save to the File Cabinet without complaint. The failure only appears when NetSuite tries to execute the script.
Check these first:
| Check | What to look for |
|---|---|
@NScriptType | Does the annotation match the kind of script you are deploying? |
| Return object | Does define() return at least one valid entry point function? |
| Function name | Does the key match NetSuite's expected name exactly, including case? |
| SDF script XML | Are old 1.0 function-name fields still pointing at removed globals? |
The cause is usually one of these mistakes.
Missing return statement. In SuiteScript 1.0, event handler functions are global, so defining them is enough. In 2.1, you must explicitly return them:
// Wrong - functions declared but never returned
define(['N/record'], (record) => {
const beforeSubmit = (context) => {
// logic here
};
// no return statement - NetSuite cannot find beforeSubmit
});
// Right
define(['N/record'], (record) => {
const beforeSubmit = (context) => {
// logic here
};
return { beforeSubmit };
});Mismatched function name. The key in the return object must exactly match the entry point name NetSuite expects for the declared script type. Case matters.
| Script type | Required return keys |
|---|---|
UserEventScript | One or more of: beforeLoad, beforeSubmit, afterSubmit |
ClientScript | One or more of: pageInit, fieldChanged, postSourcing, sublistChanged, lineInit, validateField, validateLine, validateInsert, validateDelete, saveRecord |
ScheduledScript | execute |
MapReduceScript | getInputData (plus any of map, reduce, summarize) |
Restlet | One or more of: get, post, put, delete |
Suitelet | onRequest |
Annotation mismatch. If @NScriptType says ScheduledScript but the return object has { onRequest }, NetSuite looks for execute, does not find it, and throws the error. Double-check that @NScriptType in the header comment matches the functions you are returning.
Sublist indexing: from 1-based to 0-based
This is the most common mistake in a 1.0-to-2.1 migration, and the most likely to pass code review. The error only fires at the boundary of the loop.
In SuiteScript 1.0, sublists are 1-based. Line 1 is the first line. If nlapiGetLineItemCount('item') returns 3, you loop from i = 1 to i <= 3. In SuiteScript 2.1, sublists are 0-based, consistent with JavaScript arrays. Line 0 is the first line, and the same three-line sublist loops from i = 0 to i < 3.
1.0:
var lineCount = nlapiGetLineItemCount('item'); // returns 3
for (var i = 1; i <= lineCount; i++) {
var item = nlapiGetLineItemValue('item', 'item', i);
var qty = nlapiGetLineItemValue('item', 'quantity', i);
}2.1:
const lineCount = rec.getLineCount({ sublistId: 'item' }); // returns 3
for (let i = 0; i < lineCount; i++) {
const item = rec.getSublistValue({ sublistId: 'item', fieldId: 'item', line: i });
const qty = rec.getSublistValue({ sublistId: 'item', fieldId: 'quantity', line: i });
}If you translate the 1.0 loop literally into 2.1 (starting at i = 1 and running i <= lineCount), the first line of every sublist is silently skipped and the final iteration throws SSS_INVALID_SUBLIST_OPERATION. It will pass most basic tests. The boundary case usually only fires in production when a transaction has exactly the number of lines your test cases did not cover.
The same shift applies to selectLine(), insertLine(), removeLine(), and findSublistLineWithValue(). Every 1.0 line number reference needs to be decremented by 1.
Dynamic and standard record modes
SuiteScript 1.0 handled record modes implicitly. In SuiteScript 2.1, the mode is explicit: using the wrong mode's APIs causes a runtime error.
Dynamic mode (isDynamic: true) mirrors the UI. Sourcing fires automatically, field-change logic runs, and sublist lines must be selected and committed before values can be set:
const rec = record.create({
type: record.Type.SALES_ORDER,
isDynamic: true
});
rec.selectNewLine({ sublistId: 'item' });
rec.setCurrentSublistValue({ sublistId: 'item', fieldId: 'item', value: 456 });
rec.setCurrentSublistValue({ sublistId: 'item', fieldId: 'quantity', value: 5 });
rec.commitLine({ sublistId: 'item' });Standard mode (the default, isDynamic: false) does not fire sourcing or field change events. Line access is direct by index:
const rec = record.create({
type: record.Type.SALES_ORDER,
isDynamic: false
});
rec.setSublistValue({ sublistId: 'item', fieldId: 'item', line: 0, value: 456 });
rec.setSublistValue({ sublistId: 'item', fieldId: 'quantity', line: 0, value: 5 });In dynamic mode, use the current-line APIs: setCurrentSublistValue() requires selectNewLine() (or selectLine()) first, and commitLine() after. setSublistValue() is documented for standard mode only.
When converting a script that manipulates sublists, identify which mode the original was using implicitly. If it relied on sourcing to populate dependent fields (price from item, rate from currency), use dynamic mode. If it was setting known values without needing the cascade, standard mode is faster and simpler.
The core API translation table
The table below covers the 1.0 functions that most often appear in migration work, with their 2.1 equivalents.
Loading and saving records
1.0:
var rec = nlapiLoadRecord('salesorder', 1234);
nlapiSubmitRecord(rec);2.1:
const rec = record.load({ type: record.Type.SALES_ORDER, id: 1234 });
rec.save();record.Type.SALES_ORDER is a constant defined by N/record. You can pass the string 'salesorder' directly, but the constants make refactoring easier and avoid typos.
Getting and setting field values
1.0:
var val = nlapiGetFieldValue('custbody_approval_status');
nlapiSetFieldValue('custbody_approval_status', 'approved');2.1 (server-side, on a loaded record):
const val = rec.getValue({ fieldId: 'custbody_approval_status' });
rec.setValue({ fieldId: 'custbody_approval_status', value: 'approved' });2.1 (client-side, in a Client Script event):
const val = context.currentRecord.getValue({ fieldId: 'custbody_approval_status' });
context.currentRecord.setValue({ fieldId: 'custbody_approval_status', value: 'approved' });Submitting field updates without loading the full record
1.0:
nlapiSubmitField('salesorder', 1234, 'custbody_processed', 'T');2.1:
record.submitFields({
type: record.Type.SALES_ORDER,
id: 1234,
values: { custbody_processed: true }
});record.submitFields() costs 10 governance units on a transaction record, one-third of a full load-and-save cycle (record.load() at 10 units plus rec.save() at 20). Use it when you only need to update a small number of body fields and do not need the full record object. Note that submitFields() cannot update sublist line items or subrecords; those require a full load-and-save.
Searching
1.0:
var filters = [new nlobjSearchFilter('status', null, 'anyof', ['A', 'B'])];
var cols = [new nlobjSearchColumn('internalid'), new nlobjSearchColumn('tranid')];
var results = nlapiSearchRecord('salesorder', null, filters, cols);
if (results) {
for (var i = 0; i < results.length; i++) {
var id = results[i].getValue('internalid');
}
}2.1:
const results = search.create({
type: search.Type.SALES_ORDER,
filters: [['status', search.Operator.ANYOF, ['A', 'B']]],
columns: [
search.createColumn({ name: 'internalid' }),
search.createColumn({ name: 'tranid' })
]
}).run().getRange({ start: 0, end: 1000 });
results.forEach(result => {
const id = result.getValue({ name: 'internalid' });
});nlapiSearchRecord() returned an array of nlobjSearchResult objects or null when there were no results. search.create().run().getRange() always returns an array (empty when there are no results), so the if (results) null check in every piece of 1.0 code needs to be removed.
Outbound HTTP requests
1.0:
var response = nlapiRequestURL(
'https://api.example.com/webhook',
JSON.stringify({ orderId: 1234 }),
{ 'Content-Type': 'application/json' },
'POST'
);
var body = response.getBody();2.1:
const response = https.post({
url: 'https://api.example.com/webhook',
body: JSON.stringify({ orderId: 1234 }),
headers: { 'Content-Type': 'application/json' }
});
const body = response.body;The https module returns a plain object with a body property (string), code (integer HTTP status), and headers (object). The 1.0 nlapiRequestURL() returned an nlobjResponse with methods like getBody() and getCode(). Forgetting to update the property access produces a runtime error.
Looking up fields without loading the record
const fields = search.lookupFields({
type: search.Type.CUSTOMER,
id: customerId,
columns: ['companyname', 'custentity_credit_tier']
});
const name = fields.companyname;search.lookupFields() costs 5 governance units versus 10 for a record.load(). If you are migrating 1.0 code that loads a full record just to read one or two field values, replace it with this.
Logging
1.0:
nlapiLogExecution('DEBUG', 'Processing order', orderId);
nlapiLogExecution('ERROR', 'Failed', e.toString());2.1:
log.debug({ title: 'Processing order', details: orderId });
log.error({ title: 'Failed', details: e.toString() });The four level-specific methods map directly: log.debug, log.audit, log.error, log.emergency. The log object is a global in 2.1, so you do not need to add it to your define() dependency array, though you can for clarity.
Date handling
nlapiAddDays() and nlapiAddMonths() have no 2.1 equivalents. Use native JavaScript Date methods instead. nlapiDateToString() and nlapiStringToDate() are replaced by format.format() and format.parse() from N/format.
1.0:
var today = new Date();
var futureDate = nlapiAddDays(today, 30);
var dateStr = nlapiDateToString(futureDate, 'date');
nlapiSetFieldValue('custbody_due_date', dateStr);2.1:
const today = new Date();
today.setDate(today.getDate() + 30); // native JS; no nlapiAddDays equivalent
const dateStr = format.format({ value: today, type: format.Type.DATE });
rec.setValue({ fieldId: 'custbody_due_date', value: dateStr });For nlapiAddMonths(), use date.setMonth(date.getMonth() + n). Native setMonth() handles year rollover automatically (October + 3 = January of next year), but watch for month-end edge cases: 31 January + 1 month resolves to 2 or 3 March depending on the year.
Error handling
The error object properties changed between versions. e instanceof nlobjError and e.getCode() in catch blocks need rewriting.
1.0:
try {
var rec = nlapiLoadRecord('salesorder', 99999);
} catch (e) {
if (e instanceof nlobjError) {
nlapiLogExecution('ERROR', e.getCode(), e.getDetails());
}
}
throw nlapiCreateError('VALIDATION_FAILED', 'Customer is required', true);2.1:
try {
const rec = record.load({ type: record.Type.SALES_ORDER, id: 99999 });
} catch (e) {
if (e.name) { // SuiteScript error
log.error({ title: e.name, details: e.message });
} else {
log.error({ title: 'Unexpected error', details: e.toString() });
}
}
throw error.create({ name: 'VALIDATION_FAILED', message: 'Customer is required', notifyOff: true });The property mapping:
| SS1.0 | SS2.1 |
|---|---|
e.getCode() | e.name |
e.getDetails() | e.message |
e.getId() | e.id |
e.getStackTrace() | e.stack |
For error.create(): the 1.0 third parameter was suppressNotification (true = suppress). In 2.1, notifyOff: true also suppresses notification. The semantics are the same; only the name changed. Add N/error to your define() dependency array or the module will not be available.
Reserved word conflicts: log and util
Two global objects introduced in SuiteScript 2.1 (log and util) conflict silently with any 1.0 variable that used those names. There is no compile error; the global is simply shadowed.
SS1.0 (legal):
function afterSubmit(type) {
var log = 'Transaction completed'; // valid variable name in SS1.0
nlapiLogExecution('DEBUG', 'Status', log);
}SS2.1 (silently broken):
const afterSubmit = (context) => {
const log = 'Transaction completed'; // shadows the log global
log.debug({ title: 'Status', details: log }); // TypeError: log.debug is not a function
};Before converting any script, search for var log, var util, let log, let util and rename them. It takes five seconds and saves considerable head-scratching.
What cannot be lifted and shifted
Some 1.0 patterns need redesign rather than function translation.
nlapiGetContext() to check the current user, role, or execution context becomes runtime.getCurrentUser(), runtime.getCurrentScript(), and runtime.executionContext. The API equivalents exist, but scripts that make logic decisions based on execution context often have subtle assumptions about which contexts are possible. Review the logic before translating the calls.
Client scripts that use pageInit to set field defaults. pageInit in the asynchronous UI can run before all form fields are ready. Scripts that read a field in pageInit to conditionally set another field are fragile in both 1.0 and 2.1. The migration is an opportunity to move that logic to fieldChanged on the trigger field, or to postSourcing if the value comes from a sourced field.
Scheduled Scripts that use nlapiYieldScript() to checkpoint progress. Both nlapiSetRecoveryPoint() and nlapiYieldScript() are removed in 2.1. The replacement architecture is Map/Reduce: separate phases with their own governance budgets, built-in parallelism, and automatic retry on failure. A Scheduled Script using the yield pattern should be redesigned as Map/Reduce, not translated.
Running both versions in parallel
During a migration, you cannot run a 1.0 and a 2.1 version of the same script on the same deployment simultaneously. The script record is either 1.0 or 2.1; the version is set at the script level, not the deployment level.
The practical approach is to deploy the 2.1 version under a separate deployment record, initially in Testing status (runs for administrators only), while the 1.0 version stays live. Test the 2.1 version thoroughly in sandbox against real data. When you are confident, flip the 1.0 deployment to Inactive and the 2.1 deployment to Released.
Keep the 1.0 file in the File Cabinet for a reasonable rollback window (say 30 days), then delete it. Leaving dead script files indefinitely creates confusion about which version is current.
Frequently asked questions
How long does a SuiteScript 1.0 to 2.1 migration take?
It depends on complexity. A simple script under 100 lines with fewer than 10 unique nlapi calls typically takes one to two hours. A large script with 500+ lines, dynamic sublist manipulation, timezone handling, or the recovery point pattern can take a full day or more. Score your scripts against the factors in the complexity table before committing to a timeline.
Will my 1.0 scripts stop working if I do not migrate?
Not immediately, but SuiteScript 1.0 has no documented end-of-support date and Oracle breaks it silently through UI and platform changes without announcing a deprecation. The more pressing issue is capability: N/llm, N/query, and the Custom Tool script type are 2.1 only. Staying on 1.0 means falling behind on every new API Oracle ships.
What is the most common mistake in a SuiteScript 1.0 to 2.1 migration?
Sublist indexing. SuiteScript 1.0 uses 1-based line numbers; SuiteScript 2.1 uses 0-based. A literally translated sublist loop skips the first line and throws SSS_INVALID_SUBLIST_OPERATION on the last iteration, in a way that usually passes basic tests until the boundary case fires in production.
Do I need to update the Script record in NetSuite after migrating the file?
Yes. Point the script record to the new 2.1 file. In SuiteScript 2.1, entry point function names are read from the return object in define() rather than from the script record fields, so those fields can be cleared. For SDF deployments, remove the beforeloadfunction, beforesubmitfunction, and aftersubmitfunction elements from the script XML.
What does "entry point scripts must implement one script type function" mean in SuiteScript 2.1?
NetSuite has loaded the script file but cannot find a valid entry point for the declared script type. Check that the file has the correct @NScriptType annotation, that the function name matches the script type, and that the function is returned from define(). A UserEventScript needs beforeLoad, beforeSubmit, or afterSubmit. A ScheduledScript needs execute. A Suitelet needs onRequest.
If you have a body of 1.0 scripts to migrate and want it done as a genuine modernisation rather than a literal translation, that is one of the services I offer. Bring a representative script and we can scope it from there.
Need to know what a 1.0 to 2.1 migration would involve?
Bring one representative script or a list of scripts. I'll help you assess migration complexity, risk, and the sensible order of work.
Book a migration review