netsuite · saved-search · suiteql
NetSuite Saved Search Limitations: When to Switch to SuiteQL, a Workbook or a Script
A saved search looped in a script stops at 4,000 results, and the UI shows 500 rows. Here is where each limit starts and when SuiteQL or a workbook fits.
A script that loops a saved search stops at 4,000 results
Say a saved search of open order lines shows a Total well past ten thousand. The scheduled script that walks the same search and writes a flag on each line handles 4,000 of them and goes no further. Nobody changed the criteria. The code looks like most examples you will find:
const s = search.load({ id: 'customsearch_open_so_lines' });
s.run().each((result) => {
markLine(result);
return true;
});
This is the first saved search limitation most developers meet, and it is not a bug. Oracle's page on search result limits says that a search loaded with search.load and read through Search.run() and a ResultSet returns up to 4,000 results, and the reference for ResultSet.each() describes it as processing up to 4,000 results at a time. The sibling method ResultSet.getRange() supports an unlimited result set but returns only 1,000 rows per call, with start inclusive and end exclusive. Both cost 10 governance units per call, and any work done inside the each() callback counts against the script's own budget.
The fix is to page the search instead of iterating it:
/**
* @NApiVersion 2.1
*/
define(['N/search'], (search) => {
function forEachResult(searchId, handle) {
const s = search.load({ id: searchId });
// Paging needs a unique sort, or rows can repeat or go missing between pages.
s.columns = s.columns.concat(
search.createColumn({ name: 'internalid', sort: search.Sort.ASC })
);
const paged = s.runPaged({ pageSize: 1000 });
paged.pageRanges.forEach((range) => {
const page = paged.fetch({ index: range.index });
page.data.forEach(handle);
});
}
return { forEachResult };
});
Search.runPaged() costs 5 units and returns at most 1,000 pages. A page holds between 5 and 1,000 results, 50 by default, so a page size of 1,000 lets one run cover up to a million rows. Each PagedData.fetch() costs another 5 units. The internal ID column is there because the runPaged() reference asks for a unique and unambiguous sort order: without one, the documentation warns that results can be duplicated or skipped between pages.
Where paging stops helping
Paged results are not cached. Oracle notes that if records change while the script is moving through the pages, the result set can change under it. That matters most for the exact script above, because a script that edits the records its own search selects is changing the result set as it reads it. If marking a line makes it drop out of the criteria, the later pages shift and some lines are never visited. In that case collect the internal IDs first and do the writes in a second pass, or move the job to a map/reduce script, which is built for this kind of batch work.
Governance is the other ceiling. Fetching is cheap, but loading and saving a record for every result is not, and a scheduled script runs out of units long before it runs out of pages. Time is a third one: Oracle's support answer on SSS_SEARCH_TIMEOUT says the error is thrown when a single search operation takes more than 180 seconds. Paging fixes the 4,000-row wall. It does not make a scheduled script able to update a hundred thousand records.
The screen shows 500 rows while the Total stays correct
The second limit lives in the browser. A saved search renders a fixed 500-row window with no warning, and adding &size= to the URL does not change it. The Total at the bottom of the page counts every row, and the CSV export contains every row, so the data is all there. We cover the details in why a saved search shows only 500 rows.
For a large result that someone needs as a file, the UI export works until the search itself becomes slow. Past that point NetSuite has a better tool than waiting on a browser tab. Persist Search runs a saved search in the background for up to three hours and writes the result to a CSV file. You start it from Reports > Saved Searches > All Saved Searches with the Persist (CSV) link, and the file appears under Reports > Scheduled Searches > Search Results, where it stays available for seven days. A request that sits in Pending for 24 hours is marked Failed. Roles other than administrator need the Persist Search permission. The Saved Searches chapter of the help gives the same advice for long runs: schedule the search as an email, or persist the results.
Count in a summary search is a distinct count
People switch away from saved searches over this one without knowing it is a limitation. The Count summary type counts distinct values, so a Count of internal ID on a transaction line search counts transactions, not lines. The number looks plausible and is wrong for anyone who wanted lines. The workaround is a Formula (Numeric) column with the value 1 and a Sum summary, which counts every row. The full explanation is in Count in a summary search is COUNT DISTINCT.
This is a limitation of expressiveness rather than of size. Summary searches give you a fixed set of aggregate types per column, and the moment a report needs two different grains in one result, such as a count of lines and a count of distinct customers side by side, the formula workarounds start to stack. That is usually the moment to stop.
The join list is fixed by the search type
Every saved search type comes with a fixed table of related records that it can join to. Oracle documents that table per search type, and you see the same list in the criteria filter dropdown and in the result field lists. In a formula you reach a joined field with dot notation inside the braces, so a transaction search that joins to the vendor can use {vendor.creditlimit}. Plain fields on the searched record are referenced by ID, such as {amount}. If the record you need is not on the list for that search type, no formula will reach it.
SuiteAnalytics Workbook is the first alternative here. Its datasets can join several record types, including ones that are more than one join away from the root record type. It reads from the analytics data source, and that brings a catch that costs people a day: Oracle states that some field names and locations differ from search, and some calculated fields from search are not ported, so recreating a saved search in a workbook can require different fields or different joins. Plan for a rebuild, not a copy.
SuiteQL is the second alternative, and the one with no fixed join list at all. It is a SQL dialect based on SQL-92 that also accepts Oracle SQL syntax, though not both in the same query. Oracle recommends the Oracle syntax, because converting SQL-92 can lead to performance problems such as timeouts. The ANSI join syntax also has a trap that has nothing to do with the documentation. A RIGHT JOIN from transaction to transactionLine runs as an inner join, and FULL OUTER JOIN is quietly downgraded to a left join; we wrote that up in SuiteQL RIGHT JOIN and FULL OUTER silently behave like INNER. Use LEFT JOIN or the Oracle (+) form.
Text columns stop at 4,000 bytes
Text columns in search results have a 4,000-byte limit. Oracle phrases it in bytes for a reason: in English that is roughly 4,000 characters, but character sets that need more bytes per character lower the visible limit. A Multiple Select field shown in results has a 4,000-character limit as well. Long concatenations with || and memo fields are where this tends to show up first.
The same help page explains a related surprise. In a transaction search, a field that holds multiple values makes the record appear multiple times in the results, and link types do the same even when the link field is not in your columns. If your row count jumped after adding one column, that column is the reason.
When the result has to go to a spreadsheet, check how the header row looks too. Two result columns with the same label produce two identical CSV headers, which breaks imports that key on header names. The fix is custom labels, covered in duplicate column headers in a saved search CSV.
REST can run a saved search but cannot change it
Since 2026.2, REST web services can run a saved search and return its rows. It cannot create, edit or delete one, cannot override the search's criteria or sort at run time, and cannot read the definition back. An integration that needs a different filter needs a different saved search, maintained by hand in the UI. We went through the endpoint in REST saved search in NetSuite 2026.2. For an integration that needs to build its filter at run time, SuiteQL over REST is the better fit, within the result caps described in the next section.
SuiteQL removes the join limit and adds its own ceilings
Here is the paged loop from the first section written as a SuiteQL query:
/**
* @NApiVersion 2.1
*/
define(['N/query'], (query) => {
function forEachRow(handle) {
const paged = query.runSuiteQLPaged({
query: `
SELECT t.id, t.tranid, t.trandate, t.entity
FROM transaction t
ORDER BY t.id`,
pageSize: 1000
});
paged.pageRanges.forEach((range) => {
const page = paged.fetch({ index: range.index });
page.data.asMappedResults().forEach(handle);
});
}
return { forEachRow };
});
The ORDER BY is not decoration. The runSuiteQLPaged() reference carries the same warning as the search version: specify a unique, unambiguous sort order, or rows can be duplicated or missing. Paged SuiteQL returns at most 1,000 pages, and in N/query PagedData.fetch() has no governance cost of its own.
The ceilings are different from a saved search, not absent. Without the SuiteAnalytics Connect feature, SuiteQL.run() and runSuiteQLPaged() return at most 100,000 results; with Connect enabled there is no documented cap. The REST query endpoint follows the standard collection paging of 1,000 results per page and returns at most 100,000 results in total; past that, Oracle points you to SuiteAnalytics Connect with the NetSuite2.com data source. Aliases are not case-preserving either: SELECT id AS myID comes back from asMappedResults() with the key myid, and Oracle warns that the casing of record and field names in results can change after a release. Never key on casing.
There is also a trap for anyone who replaces a saved search Total with the REST query envelope: totalResults is not a row count. On a large result set it reports a cap derived from the page size and calls it the total, as shown in why SuiteQL REST totalResults does not match COUNT(*). Run a separate SELECT COUNT(*) when you need the number.
Workbook exports have a quirk to check before you promise someone a clean file. Oracle lists, among the known workbook limitations, that CSV export wraps text in quotes and prefixes values starting with -, +, = or @ with an apostrophe, as protection against CSV injection. The same formatting applies when workbook and dataset results are exported to CSV through SuiteScript and SuiteQL. A negative number stored as text can arrive as '-120.
Keep the saved search where it delivers, move the work where it computes
My opinion: a saved search is a delivery mechanism first and a query language second, and it should be judged that way. It is the only one of these tools that plugs straight into dashboard portlets, reminders, scheduled emails to people who never open NetSuite, workflow conditions and script parameters. Those consumers expect a saved search ID, and replacing one with a SuiteQL query means writing a script to do what a checkbox used to do.
So the rule we use is about what the result feeds, not about how hard the query is. If the result feeds a person through one of those channels, keep it as a saved search and work around the limits above: paging in scripts, Persist Search for big files, Sum of 1 for row counts, custom labels for exports. If the result feeds a script, an integration or an analysis, and you are fighting the join list or the summary types, move it to SuiteQL. Choose a workbook when the people who maintain the report will not maintain code, and they accept that some fields will live in different places. Formula-level problems such as NULL handling and main line belong to a different list, covered in saved search formulas: what breaks and why.
Translating a search into a SuiteQL query is mostly mechanical but fiddly, because field IDs differ between the two and every join has to be spelled out. MokuBot works in a side panel inside your NetSuite account; it can read a saved search, draft the equivalent SuiteQL and run it, so you can compare the totals before you switch anything over.
If the report is past what either tool does comfortably, such as month-end reporting across subsidiaries that needs custom records no single search type reaches, that is design work rather than a syntax question. Write to sales@mokuhub.com with what the report has to show, and we will tell you which of these tools it belongs in.