NetSuite 2026.2 Changed the Default SuiteQL Sort Order: What It Breaks

MokuHub10 min read
netsuitesuiteqlpaginationrest-web-servicessuiteanalytics

A SuiteQL query against transaction with no ORDER BY returns its rows in a different order in 2026.2 than it did before. Oracle changed the default sort field from Transaction.tranDisplayName to Transaction.tranDate. Nothing throws, nothing logs, and the row set is the same set of rows. Only the sequence moved. Whether that matters to you depends entirely on what the caller does with the sequence, and most callers were never written with an opinion about it.

What the release note says, exactly

From the SuiteAnalytics section of the 2026.2 release notes:

With the NetSuite 2026.2 release, SuiteQL queries and Analytics datasets based on generic transactions use Transaction.tranDate as the default sort field when no sort order is specified. This change helps prevent performance issues.

Previously, these queries and datasets used Transaction.tranDisplayName as the default sort field.

This change may alter the order of returned results. Queries and datasets that specify a sort order are not affected. If result order is important, specify the required sort order.

Read the scope carefully before you go looking for damage. This is about queries and datasets built on generic transactions, not about every SuiteQL statement in your account. A query over customer or item is not covered by this note. And a query that already carries an ORDER BY is explicitly excluded, which is the whole point of what follows.

The stated reason is performance, and it is a reasonable one. tranDisplayName is a display string. tranDate is a date column. Sorting a large transaction set by the second is cheaper than sorting it by the first, and if you never asked for an order, Oracle is free to pick the cheaper one.

The old default was never a promise

The uncomfortable part of this change is not the change. It is what the change tells you about the code that noticed it.

If a job broke on the upgrade, that job was reading an ordering guarantee out of a query that never made one. SQL without ORDER BY returns rows in whatever order the engine finds convenient, and "convenient" is a function of the plan, the indexes, the data volume and, as of 2026.2, a product decision. The order was stable for years, so it got treated as a fact. It was a coincidence with a long run.

There is a second-order version of the same mistake that survives the upgrade untouched, and it is the one worth spending the rest of this article on: a query that does have an ORDER BY, on a column that is not unique.

Where a reordering turns into wrong data

Reordering by itself is harmless. Reordering plus paging is not.

The paged read is the standard shape for anything that moves more than a few thousand transactions. query.runSuiteQL(options) costs 10 governance units and returns at most 5,000 results, so past that you either move to query.runSuiteQLPaged(options), also 10 units, or you page over REST. Both do the same thing underneath: they run the query more than once and hand you a window of it each time.

Oracle is explicit about the consequence, in the runSuiteQLPaged reference:

You must specify a sorting order in the query definition when using this method to avoid duplicate or missing results. The query definition must provide a unique and unambiguous sorting order with a specified precedence.

Three words in that sentence carry the whole load. Unique. Unambiguous. Precedence.

Here is the failure it is describing. Page 1 asks for rows 1 to 1000. Page 2 asks for rows 1001 to 2000. Those are two separate executions, and the row set is re-derived each time. If two rows tie on the sort key, the engine may order them one way on the first execution and the other way on the second. A row that sat at position 1000 on the first call sits at position 1001 on the second, and you write it to your file twice. Its neighbour sits at 1000 both times and you never write it at all.

No error. No warning. Your record count is right, because one duplicate paid for one omission.

ORDER BY trandate is not the fix

The obvious reaction to the release note is to add the new default back explicitly, which reproduces 2026.1 behaviour in name but not in substance:

SELECT t.id, t.tranid, t.trandate, t.foreigntotal
FROM transaction t
WHERE t.type = 'CustInvc'
  AND t.trandate >= TO_DATE('2026-01-01', 'YYYY-MM-DD')
ORDER BY t.trandate

Invoices are dated by day. A mid-size account books hundreds of them on the same trandate, so this sort has hundreds of ties in every batch, and the paging failure above applies to every one of the tie groups that happens to straddle a page boundary. The query now has an explicit sort order and is still not safe to page.

What it needs is a last column that cannot tie:

SELECT t.id, t.tranid, t.trandate, t.foreigntotal
FROM transaction t
WHERE t.type = 'CustInvc'
  AND t.trandate >= TO_DATE('2026-01-01', 'YYYY-MM-DD')
ORDER BY t.trandate, t.id

t.id is the internal id, one per record, so the ordering is now total. Every row has exactly one position and both executions agree on it. That is what "unique and unambiguous, with a specified precedence" means in practice: sort by whatever you want the human to see, then append a key.

Note what ORDER BY t.id alone would mean, because it is tempting as a one-line fix. Internal ids are assigned at record creation. Ordering by id is creation order, which is not transaction-date order, and for anything backdated the two disagree. It makes paging correct and the report wrong. Keep the business sort first and use the id as a tiebreaker, not as a replacement.

Paging over REST, where the window is a URL

The REST endpoint has the same problem with a different surface. A SuiteQL call is a POST to the suiteql resource with the Prefer: transient header, which the documentation lists as required, and the window is set on the URL:

curl -X POST 'https://ACCOUNTID.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql?limit=1000&offset=0' \
  -H 'Prefer: transient' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: OAuth ...' \
  -d '{
    "q": "SELECT t.id, t.tranid, t.trandate FROM transaction t WHERE t.type = ? AND t.trandate >= TO_DATE(?, '\''YYYY-MM-DD'\'') ORDER BY t.trandate, t.id",
    "params": ["CustInvc", "2026-01-01"]
  }'

The params array in that body is itself a 2026.2 addition. Bound parameters existed in the N/query module already, where options.params has always accepted a list of values for ? placeholders, but the REST endpoint took only a query string until this release. If you have been building REST SuiteQL by concatenating user input into q, that is now fixable without a Suitelet in front of it.

Two things about the paging parameters are easy to get wrong. The collection paging documentation, which the SuiteQL endpoint points at for offset behaviour, sets a default of 1,000 results per page over a maximum of 1,000 pages, and requires that the offset be divisible by the limit, so limit=1000&offset=1500 is not a valid window. Those same two numbers are where the total ceiling comes from. The SuiteQL page states one figure only, 100,000 results, and names SuiteAnalytics Connect as the way past it without a number; the million you will see quoted is the collection paging arithmetic of 1,000 pages by 1,000 rows. The runSuiteQLPaged reference describes its own limit with Connect as no limit at all. Treat 100,000 as the number you can plan against and confirm anything above it in your own account.

There is also a trap in the response body. The totalResults field looks like a row count and is not one: it reports the smaller of the real row count and limit times 1000, so a loop that trusts it as a termination condition will stop early on small page sizes. Run a separate SELECT COUNT(*) if you need the real number.

And do not try to move the window into the SQL. The OFFSET n ROWS clause is parsed and then silently discarded on both the N/query engine and the REST endpoint, even with an explicit ORDER BY, so every page comes back as page one. The row-limiting half of that syntax does work: FETCH FIRST n ROWS ONLY is the supported form, while LIMIT n is rejected outright by the parser.

Keyset paging removes the dependency instead of managing it

A total ordering makes offset paging correct against a static table. Transaction tables are not static. If someone books an invoice dated last week while your sweep is on page 12, every subsequent page shifts by one and you skip a record, and no amount of sort determinism prevents that, because the problem is that row positions are being recomputed against a table that changed.

Keyset paging does not ask for positions. It asks for "the next 1,000 rows after this key", which is stable regardless of what was inserted behind you:

/**
 * @NApiVersion 2.1
 */
define(['N/query'], (query) => {
    const PAGE_SIZE = 1000;

    const collectInvoices = (sinceDate) => {
        const rows = [];
        let lastId = 0;

        while (true) {
            const page = query.runSuiteQL({
                query:
                    'SELECT t.id, t.tranid, t.trandate, t.foreigntotal ' +
                    'FROM transaction t ' +
                    "WHERE t.type = 'CustInvc' " +
                    "  AND t.trandate >= TO_DATE(?, 'YYYY-MM-DD') " +
                    '  AND t.id > ? ' +
                    'ORDER BY t.id ' +
                    `FETCH FIRST ${PAGE_SIZE} ROWS ONLY`,
                params: [sinceDate, lastId]
            }).asMappedResults();

            if (page.length === 0) {
                break;
            }

            rows.push(...page);
            lastId = page[page.length - 1].id;
        }

        return rows;
    };

    return { collectInvoices };
});

The sort key and the cursor key are the same column, which is what makes the loop correct. ORDER BY t.id here is not a reporting decision, it is the mechanism. Sort the output afterwards if the consumer wants it by date.

Two constraints on that loop. Each runSuiteQL call costs 10 units, so a sweep of 40,000 invoices spends 400 units before it does anything else, which puts it in a map/reduce or a scheduled script rather than a user event. And the page size is the one value in that statement being interpolated into the string rather than bound, which is why it is a module constant here. The moment a page size arrives from a request, validate it as an integer before it reaches the string, because params protects the two placeholders and nothing else. The @NApiVersion 2.1 tag is load-bearing too: const, arrow functions and template literals are 2.1 language features, and the same file entered as a 2.0 script will not compile.

Where this stops working

Keyset paging needs a unique, sortable column in the projection, and there are result sets that do not have one.

Aggregates are the clearest case. SELECT t.entity, SUM(t.foreigntotal) FROM transaction t GROUP BY t.entity has no id to key on, and adding one to the projection changes the grouping. For grouped output you are back to offset paging, which means you need the ordering to be total across the grouping columns, and if it cannot be, the honest answer is to page the underlying rows and aggregate on your side.

Joins that multiply rows are the other case. A query joining transaction to transactionaccountingline returns several rows per transaction, one per accounting line, so t.id is no longer unique in the result and stops working as a cursor. The composite key of transaction id plus line id is unique, and a keyset loop over a composite key needs the compound comparison rather than a single >, which is more code than most sweeps deserve. Selecting the transaction ids first, then fetching lines per batch, is usually the smaller change.

Finding the queries that need any of this is a text search rather than a query: every runSuiteQL and runSuiteQLPaged call site, every workbook dataset over transactions, and every stored q string in an external integration. In an account where those are scattered across Suitelets, map/reduce stages and someone's Postman collection, that sweep is the slow part, and it is the sort of mechanical rewrite an assistant with account access such as MokuBot can do faster than a person reading files one at a time.

The rule underneath all of it is one line, and it is worth stating as a position rather than a tip: a SuiteQL query whose result feeds a loop, a file or a comparison is incorrect if it has no ORDER BY ending in a unique column, whether or not it currently returns the rows you expect. 2026.2 did not introduce that defect. It just moved enough rows around that some of you can finally see it.