Block Top-Level Locations on NetSuite Transactions
When the Locations feature is enabled in NetSuite, every transaction form exposes a Location dropdown on both the header and many line records.

On this page
When the Locations feature is enabled in NetSuite, every transaction form exposes a Location dropdown on both the header and many line records. That dropdown lists every active location in the account, including parent locations like "United States" or "East Coast Region" that exist purely for reporting hierarchy. Users pick one of those parents by accident, the subsidiary and department fields go blank, and downstream reports skew because revenue gets parked at a non-inventory node.
The fix is to filter parent locations out of the dropdown so users can only pick leaf-level inventory or fulfillment locations.
Why the standard Location field can't do this
The Location field on a transaction is a regular list/record reference on the location record type. NetSuite renders every active location that the current user's role can access, ordered by internalid. The UI offers no "hide parents" checkbox anywhere on the form, on the location record, or under Setup > Accounting > Accounting Preferences. The field will keep showing parents as long as they are active and the role can read them.
That is why most teams end up scripting it.
Use role-based restrictions first (the cheap path)
Before writing any SuiteScript, check whether role-level restrictions solve the problem. Go to Setup > Users/Roles > Manage Roles, edit the affected role, and on the Restrictions subtab look at the Location sublist. If your only requirement is "this sales group only ever transacts in two plants," restricting their role to those leaf locations is faster than any script and has zero governance cost. The Restrictions subtab is the canonical place to set Department, Class, and Location restrictions per role; those restrictions then apply to every user signed in with that role.
Role restrictions remove the locations from the dropdown entirely for that role. They do not validate anything on the form.
The gotcha here is that role restrictions only help when the population of "allowed locations" is small and stable. If every user can transact at most locations but should never see parents, you need the script path.
The script approach: beforeSubmit on one User Event script
The discussion worry about "a lot of deployments" is real if you try to bolt a script onto every transaction type. There is no native "apply to all transactions" deployment. The clean fix is to write one User Event script and attach it to the records that matter, typically Sales Order (salesorder), Invoice (invoice), Item Fulfillment (itemfulfillment), Purchase Order (purchaseorder), Item Receipt (itemreceipt), Vendor Bill (vendorbill), Inventory Adjustment (inventoryadjustment), Inventory Transfer (inventorytransfer), Work Order (workorder), and Assembly Build (assemblybuild).
The script uses beforeSubmit to read the header location. If it is a parent, it throws and blocks save. Patching it later to also walk line-level locations (some item fulfillment forms split the location across lines) is a small change.
/**
* @NApiVersion 2.1
* @NScriptType UserEventScript
* @NModuleScope SameAccount
*/
define(['N/search', 'N/error', 'N/runtime'], (search, error, runtime) => {
const LOCATION_PARENT_FIELD = 'parent';
const buildParentLocationCache = () => {
const cache = new Set();
search.create({
type: 'location',
filters: [[LOCATION_PARENT_FIELD, 'noneof', '@NONE@']],
columns: ['internalid']
}).run().each(result => {
cache.add(String(result.getValue('internalid')));
return true;
});
return cache;
};
const beforeSubmit = (context) => {
if (context.type !== context.UserEventType.CREATE &&
context.type !== context.UserEventType.EDIT) {
return;
}
const headerLocation = context.newRecord.getValue('location');
if (!headerLocation) {
return;
}
const parents = buildParentLocationCache();
if (parents.has(String(headerLocation))) {
throw error.create({
name: 'PARENT_LOCATION_BLOCKED',
message: 'You cannot select a parent (non-inventory) location on a transaction. Pick a leaf-level location instead.'
});
}
const lineCount = context.newRecord.getLineCount({ sublistId: 'item' });
for (let i = 0; i < lineCount; i++) {
const lineLoc = context.newRecord.getSublistValue({ sublistId: 'item', fieldId: 'location', line: i });
if (lineLoc && parents.has(String(lineLoc))) {
throw error.create({
name: 'PARENT_LOCATION_BLOCKED',
message: `Line ${i + 1} uses a parent location. Switch it to a leaf location before saving.`
});
}
}
};
return { beforeSubmit };
});Deploy it once per record type, audience = all roles, status = released. That is nine deployments in the worst case, not hundreds. The search call inside buildParentLocationCache runs at most once per submit because the script instance is reused, and a parent-location lookup is cheap on typical accounts. For context on how NetSuite governs these costs, the Optimizing System Performance Guide covers the performance best practices that govern scripts like this one.
Why noneof '@NONE@' and not "is parent"
The location record's parent field is a List/Record reference. A leaf location has it empty, which in SuiteScript search filters is expressed as noneof '@NONE@'. Filtering on is 'parent' or isnotempty looks tempting but returns wrong results on records whose parent was unset during a data migration. 'noneof', '@NONE@' is the stable, version-safe idiom. If you need to confirm how NetSuite treats location as a record type and its internal IDs versus field IDs, the NetSuite Connector documentation spells out the difference between SuiteScript field IDs (lowercase) and SuiteTalk camel-case field IDs.
What about subsidiaries with multiple location hierarchies
If different subsidiaries use different location trees, the cache above will over-block. Two-line fix: pass context.newRecord.getValue('subsidiary') and filter the cache search with filters: [['subsidiary', 'anyof', subsidiaryId], 'and', [LOCATION_PARENT_FIELD, 'noneof', '@NONE@']]. The script only does one extra search per submit, still well inside governance.
Edge cases that will bite you during upgrades
- Locations turned off. If the Locations feature is disabled at the account level, the
locationfield is absent from the form and your script silently does nothing. Wrap the field read defensively and skip the throw when the field returns null. - Inactive parents. The cache currently ignores
isinactive. If an admin inactivates a parent, the script keeps blocking it; if they activate a former leaf, it suddenly passes. Add[[LOCATION_PARENT_FIELD, 'noneof', '@NONE@'], 'and', ['isinactive', 'is', 'F']]if you want mirror-image behavior. - Multi-currency and inter-company. Inter-company sales orders may legitimately have a parent location on the "from" side for reporting. Validate with Finance before turning this on there. The Intercompany Cross-Subsidiary Fulfillment feature in particular lets you fulfill from locations in the transaction subsidiary or other subsidiaries, so a blanket parent-block can break legitimate flows.
- Line-item locations. When line-item locations are enabled, you must customize the form to expose the Location column on the Items sublist, otherwise the line-level sweep in the script never fires against that form.
The script blocks save and shows a clear error message. It does not auto-correct the pick, that would silently rewrite user intent.
Verifying the fix
After deploying, open a Sales Order as a test user, pick "United States" (or whatever your top-level parent is), and hit Save. You should see "You cannot select a parent (non-inventory) location on a transaction." Switch to a leaf location, save again, confirm the record posts. Repeat on Invoice and one inventory record. Then check the Execution Log under Customization > Scripts > Scripted Records for the governance count. Anything under 100 units per submit means you have headroom for additional line-level checks later.
When you want to see exactly which scripts touched a record and when, the System Notes Guide shows you where to pull that audit trail from inside NetSuite. For a final sanity pass, double-click the Oracle NetSuite logo in the upper-left corner of any page to pull the performance details page and confirm your submit is well under the per-request budget before you roll the script to production.


