If you're just getting started with SuiteScript, one of the first things you'll notice is that NetSuite records aren't simple flat objects. Open a Sales Order and you'll see line items, addresses, and all sorts of structured data nested within the record. Before you write a single line of code against that data, it pays to understand how NetSuite organizes it. The API you need depends entirely on what you're touching.
This guide walks through the three layers you'll encounter on almost every record: body fields, sublists, and subrecords. By the end, you'll know what each one is, how they differ, and how to work with each in SuiteScript 2.1.
The Anatomy of a NetSuite Record
Think of a NetSuite record as having three tiers of data.
Body fields sit at the top level of the record. Things like the customer name on a Sales Order, a transaction date, or a status field. These are simple key/value pairs and are the easiest to work with.
Sublists are the line-item tables you see within a record. The Items tab on a Sales Order, the Expenses tab on an Expense Report, the Addressbook on a Customer. All of these are sublists. Each row is a "line," and each column in that row is a sublist field.
Subrecords are fully structured records embedded inside a parent record. They have their own set of fields, and in some cases their own sublists. The most common examples are address subrecords and Inventory Detail.
Understanding which tier your data lives on determines which SuiteScript methods you need to use.
Body Fields
Body fields are straightforward. You read them with getValue and write them with setValue.
/**
* @NApiVersion 2.1
* @NScriptType Suitelet
* @NModuleScope SameAccount
*/
define(['N/record', 'N/log'], (record, log) => {
const onRequest = (context) => {
try {
const rec = record.load({
type: record.Type.SALES_ORDER,
id: 12345
});
// Read a body field
const status = rec.getValue({ fieldId: 'status' });
log.debug({ title: 'Order Status', details: status });
// Write a body field
rec.setValue({ fieldId: 'memo', value: 'Reviewed by script' });
rec.save();
} catch (e) {
log.error({ title: e.name, details: `${e.message}\n${e.stack}` });
}
};
return { onRequest };
});
Nothing surprising here. The important thing to internalize early is that field IDs in SuiteScript are always lowercase with underscores, not the display label you see in the UI. The field labeled "PO Number" in the UI might have an ID of otherrefnum.
The best way to look up field IDs is in the NetSuite Records Browser (Help > SuiteScript > SuiteScript API > Records Browser) or by enabling field-level help in the NetSuite UI itself. I've found this step saves a lot of guesswork.
Sublists
Sublists are where most of the interesting (and sometimes painful) work happens in SuiteScript. They represent repeating groups of data. Think of them as a table attached to the record.
Two Modes: Standard and Dynamic
Before diving into the API, you need to understand the two ways to load a record, because they give you different sublist capabilities.
Standard mode is the default. It's faster and uses less memory. You can read any sublist line and write to existing lines, but you can't add new lines or remove existing ones.
Dynamic mode mimics how a user interacts with the record in the UI. It's required if you need to insert or delete lines. You enable it by passing isDynamic: true when loading or creating the record. It's slightly heavier, so only use it when you actually need it.
Reading Sublist Data
/**
* @NApiVersion 2.1
* @NScriptType Suitelet
* @NModuleScope SameAccount
*/
define(['N/record', 'N/log'], (record, log) => {
const onRequest = (context) => {
try {
const rec = record.load({
type: record.Type.SALES_ORDER,
id: 12345
// No isDynamic needed — just reading
});
// How many lines are on the Items sublist?
const lineCount = rec.getLineCount({ sublistId: 'item' });
log.debug({ title: 'Line Count', details: lineCount });
// Loop through every line and read field values
for (let i = 0; i < lineCount; i++) {
const itemId = rec.getSublistValue({
sublistId: 'item',
fieldId: 'item',
line: i
});
const qty = rec.getSublistValue({
sublistId: 'item',
fieldId: 'quantity',
line: i
});
log.debug({ title: `Line ${i}`, details: `Item: ${itemId} | Qty: ${qty}` });
}
} catch (e) {
log.error({ title: e.name, details: `${e.message}\n${e.stack}` });
}
};
return { onRequest };
});
Note that line indexes are zero-based. The first line is line: 0, the second is line: 1, and so on.
Writing to an Existing Line (Standard Mode)
If you just need to update a field on an existing line, without adding or removing lines, you can stay in standard mode:
rec.setSublistValue({
sublistId: 'item',
fieldId: 'memo',
line: 0,
value: 'Expedite this line'
});
Adding and Removing Lines (Dynamic Mode)
When you need to insert a new line, you must use dynamic mode. The pattern is always: select, set fields, commit.
/**
* @NApiVersion 2.1
* @NScriptType Suitelet
* @NModuleScope SameAccount
*/
define(['N/record', 'N/log'], (record, log) => {
const onRequest = (context) => {
try {
const rec = record.load({
type: record.Type.SALES_ORDER,
id: 12345,
isDynamic: true // Required for line insertion
});
// Step 1: Select a new line (stages it for editing)
rec.selectNewLine({ sublistId: 'item' });
// Step 2: Set fields on the currently selected line
rec.setCurrentSublistValue({
sublistId: 'item',
fieldId: 'item',
value: 67890 // Internal ID of the item
});
rec.setCurrentSublistValue({
sublistId: 'item',
fieldId: 'quantity',
value: 10
});
// Step 3: Commit the line to the record
rec.commitLine({ sublistId: 'item' });
// To remove a line:
// rec.removeLine({ sublistId: 'item', line: 0 });
rec.save({ enableSourcing: true, ignoreMandatoryFields: false });
} catch (e) {
log.error({ title: e.name, details: `${e.message}\n${e.stack}` });
}
};
return { onRequest };
});
A common mistake is forgetting to call commitLine. Until you commit, the line isn't actually part of the record. It's just staged in memory.
Sublist API Quick Reference
Here's a breakdown of which methods to use depending on what you're trying to do and which mode you're in.
- Count lines: Use
getLineCountin both standard and dynamic mode. - Read a value: Use
getSublistValuein standard mode, orgetCurrentSublistValueon the selected line in dynamic mode. - Write a value: Use
setSublistValuein standard mode, orsetCurrentSublistValuein dynamic mode. - Add a line: Not supported in standard mode. In dynamic mode, use
selectNewLine, set your fields, thencommitLine. - Remove a line: Not supported in standard mode. In dynamic mode, use
removeLine. - Edit an existing line: Use
setSublistValuein standard mode. In dynamic mode, useselectLine, set your fields, thencommitLine.
Subrecords
Subrecords are a step up in complexity. Instead of a simple value living on a line or a body field, a subrecord is a fully structured record embedded within the parent. It has its own fields, and you interact with it almost like a standalone record object.
The key distinction from sublists: a sublist gives you rows of data, but a subrecord gives you a rich object with its own schema.
Where You'll Encounter Subrecords
The two most common places new SuiteScript developers run into subrecords are:
- Address subrecords on both entity records (the Addressbook sublist on a Customer) and on transactions (the Ship To / Bill To body fields on a Sales Order).
- Inventory Detail attached to item lines on transactions that track lot numbers, serial numbers, or bin locations.
Three Access Patterns
Which method you use depends on where the subrecord lives:
- Body field on the record: Use
rec.getSubrecord({ fieldId }). - An existing line on a sublist: Use
rec.getSublistSubrecord({ sublistId, fieldId, line }). - The currently selected line (dynamic mode): Use
rec.getCurrentSublistSubrecord({ sublistId, fieldId }).
Example: Transaction Shipping Address (Body-Level Subrecord)
/**
* @NApiVersion 2.1
* @NScriptType UserEventScript
* @NModuleScope SameAccount
*/
define(['N/record', 'N/runtime', 'N/log'], (record, runtime, log) => {
const beforeSubmit = (context) => {
// Guard: only run on create/edit from the UI
if (
context.type !== context.UserEventType.CREATE &&
context.type !== context.UserEventType.EDIT
) return;
if (runtime.executionContext !== runtime.ContextType.USER_INTERFACE) return;
try {
const rec = context.newRecord;
// Access the shipping address subrecord on the transaction body
const shipAddr = rec.getSubrecord({
fieldId: 'shippingaddress'
});
// Read fields from the subrecord just like body fields
const city = shipAddr.getValue({ fieldId: 'city' });
const state = shipAddr.getValue({ fieldId: 'state' });
const country = shipAddr.getValue({ fieldId: 'country' });
log.debug({
title: 'Shipping Address',
details: `${city}, ${state}, ${country}`
});
// Write to the subrecord the same way
shipAddr.setValue({
fieldId: 'attention',
value: 'Freight Receiving'
});
// No separate save — the subrecord saves with the parent
} catch (e) {
log.error({ title: e.name, details: `${e.message}\n${e.stack}` });
}
};
return { beforeSubmit };
});
Example: Customer Addressbook (Sublist-Line Subrecord)
The Customer record's Addressbook is a sublist where each line contains an address subrecord. This combines what you've learned about sublists and subrecords:
/**
* @NApiVersion 2.1
* @NScriptType Suitelet
* @NModuleScope SameAccount
*/
define(['N/record', 'N/log'], (record, log) => {
const onRequest = (context) => {
try {
const customer = record.load({
type: record.Type.CUSTOMER,
id: 12345,
isDynamic: true // Required for subrecord access
});
// --- READ all addresses ---
const lineCount = customer.getLineCount({ sublistId: 'addressbook' });
for (let i = 0; i < lineCount; i++) {
const label = customer.getSublistValue({
sublistId: 'addressbook',
fieldId: 'label',
line: i
});
// Get the address subrecord for this line
const addrSubrec = customer.getSublistSubrecord({
sublistId: 'addressbook',
fieldId: 'addressbookaddress',
line: i
});
const city = addrSubrec.getValue({ fieldId: 'city' });
const state = addrSubrec.getValue({ fieldId: 'state' });
const zip = addrSubrec.getValue({ fieldId: 'zip' });
log.debug({
title: `Address: ${label}`,
details: `${city}, ${state} ${zip}`
});
}
// --- ADD a new address ---
customer.selectNewLine({ sublistId: 'addressbook' });
customer.setCurrentSublistValue({
sublistId: 'addressbook',
fieldId: 'label',
value: 'West Coast Warehouse'
});
customer.setCurrentSublistValue({
sublistId: 'addressbook',
fieldId: 'defaultshipping',
value: false
});
// For a new line in dynamic mode, use getCurrentSublistSubrecord
const newAddr = customer.getCurrentSublistSubrecord({
sublistId: 'addressbook',
fieldId: 'addressbookaddress'
});
newAddr.setValue({ fieldId: 'addr1', value: '500 West Ave' });
newAddr.setValue({ fieldId: 'city', value: 'Los Angeles' });
newAddr.setValue({ fieldId: 'state', value: 'CA' });
newAddr.setValue({ fieldId: 'zip', value: '90001' });
newAddr.setValue({ fieldId: 'country', value: 'US' }); // Two-letter ISO code
customer.commitLine({ sublistId: 'addressbook' });
customer.save({ enableSourcing: true, ignoreMandatoryFields: false });
} catch (e) {
log.error({ title: e.name, details: `${e.message}\n${e.stack}` });
}
};
return { onRequest };
});
Critical Rules for Subrecords
Never call .save() on the subrecord itself. It saves automatically when the parent record saves. Calling save on a subrecord independently will throw an error.
isDynamic: true is required whenever you access a subrecord. Attempting to access a subrecord on a standard-mode record will throw an error.
Transaction addresses are overrides. When you write to a shipping address subrecord on a Sales Order, you're overriding that address on the transaction only. You're not updating the underlying address stored on the Customer record. If you need to update the source address, load the Customer separately.
Country codes are ISO format. The country field expects the two-letter ISO code ('US', 'CA', 'GB'), not the full country name. Passing the full name will behave unexpectedly.
Putting It All Together: A Mental Model
Here's a simple decision tree to reach for when you're about to write code against a NetSuite record:
What are you working with?
|
+-- A single field on the main record (name, date, status, memo...)
| Body field: getValue / setValue
|
+-- A table of repeating rows (Items, Expenses, Addressbook lines...)
| +-- Just reading, or updating existing lines?
| | Standard mode: getSublistValue / setSublistValue
| +-- Need to add or remove lines?
| Dynamic mode: selectNewLine, setCurrentSublistValue, commitLine
|
+-- A structured record embedded inside the record (address, inventory detail...)
+-- Sitting directly on the record body (Ship To, Bill To)?
| getSubrecord({ fieldId })
+-- Sitting inside a sublist line?
+-- Existing line: getSublistSubrecord({ sublistId, fieldId, line })
+-- Currently selected line (dynamic): getCurrentSublistSubrecord({ sublistId, fieldId })
In practice, I've found that keeping this mental model handy eliminates a lot of confusion. Once you know which tier of data you're dealing with, the right API method usually becomes obvious.
Governance: Don't Learn This the Hard Way
One topic every new SuiteScript developer needs to understand early is governance. It's NetSuite's way of preventing scripts from consuming too many server resources. Every script execution gets a budget of governance units, and certain operations cost units against that budget.
The operations that matter most for the patterns in this guide:
record.load()costs 10 unitsrecord.save()costs 20 unitsrecord.create()costs 10 unitssearch.run()costs 10 units per page
A Suitelet gets 1,000 units by default. A Scheduled script gets 10,000. This sounds like a lot until you're loading and saving 50 records in a loop. That's 1,500 units right there.
The practical takeaway for sublists: if you need to update a field on every line of a record, load the record once, loop through the lines with setSublistValue, and save once. Don't load-and-save once per line.
For bulk processing across many records: don't use a Scheduled script with a simple loop. Use a Map/Reduce script, which is designed for high-volume processing and distributes governance across multiple stages and parallel queues. That's a topic for its own guide, but keep it in mind as you grow.
Common Mistakes to Avoid
Forgetting commitLine in dynamic mode. Your new line exists in memory but won't be persisted unless you call commitLine before saving.
Using setSublistValue when you meant setCurrentSublistValue. In dynamic mode, once you've called selectNewLine or selectLine, you must use the Current variants. Mixing them causes silent failures or errors.
Looking up field IDs by display label. The label "Ship Date" in the UI might be shipdate in the API, or it might be something else entirely. Always verify in the Records Browser. Never guess.
Ignoring execution context. User Event scripts fire in many contexts: UI, CSV import, web services, workflows. If your script should only run when a user saves a record in the browser, add a runtime.executionContext guard. Without it, your script may fire unexpectedly during imports or integrations and cause data issues.
Trying to save a subrecord independently. It always saves with the parent. Calling .save() on a subrecord object will throw an error.
Where to Go From Here
Once you're comfortable with body fields, sublists, and subrecords, the natural next steps are:
- SuiteScript Search (
N/search) for querying records across NetSuite without loading them one by one. - Map/Reduce scripts for processing large volumes of records efficiently.
- User Event scripts for hooking into record saves and using
context.typeguards responsibly. - The Records Browser, your most important reference for field IDs, sublist IDs, and subrecord field IDs.
The NetSuite Records Browser is available in your NetSuite account under Help > SuiteScript > SuiteScript API > Records Browser. Bookmark it. You'll use it constantly.
All field IDs and sublist IDs in this guide should be verified in the Records Browser for your specific record types and account configuration before deploying to production.