← All posts NetSuite & ERP 19 min read

NetSuite SuiteQL for WooCommerce: The Timestamp That Never Moves When Stock Moves

SuiteQL queries the analytics data source, not the schema behind your saved searches. Oracle documents that an item’s lastmodifieddate updates only on item-record edits, so a stock feed filtered on it returns nothing after every fulfilment.

NetSuite SuiteQL guide title card: the item timestamp that never moves when stock moves
Quick Summary

SuiteQL reads a different schema than your saved searches — and one timestamp makes that expensive

  • Oracle documents that on an item, lastmodifieddate “updates only when you update the item record”, while lastquantityavailablechange “updates when you enter or edit an inventory-affecting transaction”. An incremental stock feed filtered on the first returns an empty set after every fulfilment and logs a clean run.
  • SuiteQL queries the analytics data source. The SuiteScript Records Browser documents the search-and-report data source, and Oracle states that browser “doesn’t use the analytics data source, so it’s not useful for finding record type names and field names for SuiteScript Analytic APIs”. Resolve names in the Records Catalog.
  • There is no salesOrder table. Oracle describes reaching sales order data “through the transaction record type”, and its own examples select FROM transaction.
  • Row limiting in Oracle’s published examples is SELECT TOP n. limit and offset are query parameters on the REST request URL, not clauses inside q.
  • Prefer: transient is a required header on POST /services/rest/query/v1/suiteql.
5,000
Maximum rows one query.runSuiteQL call returns before you must page (Oracle)
100,000
Maximum results a SuiteQL query can return via REST web services (Oracle)
10
Governance units charged per query.runSuiteQL call (Oracle)
2
Data sources in play — SuiteQL reads the analytics one, the Records Browser documents the other

Moving an integration from saved searches to SuiteQL is usually presented as a syntax upgrade: you get joins, subqueries and aggregation, so you rewrite the query and move on. It is not a syntax upgrade. SuiteQL reads the analytics data source, and Oracle documents that the record type and field names in that data source may differ from the ones in the search-and-report data source your saved searches and N/search code use. Carry your saved-search field names across and you get three failure classes: names that error immediately, names that resolve to a different column than you expect, and one name that resolves cleanly, runs forever, and silently returns nothing. This post covers all three, corrects the pagination pattern most guides publish, and ends with a migration audit you can work down against a live account.

Saved searches and SuiteQL do not read the same schema

NetSuite exposes its data through more than one data source, and the two that matter here are not interchangeable. Oracle states it plainly in the SuiteScript Analytic APIs documentation: “The analytics data source provides different information than previous data sources. For instance, Search and Report features (including SuiteScript modules such as the N/search module) use a different data source that was around before the analytics data source was introduced. The record types and fields supported in this data source may be different than those supported in the analytics data source.”

The consequence for tooling is direct, and Oracle spells that out too: “The SuiteScript Records Browser uses the Search and Report data source, so you can use this browser to find record type names and field names for Search and Report functionality. This browser doesn’t use the analytics data source, so it’s not useful for finding record type names and field names for SuiteScript Analytic APIs.”

The Records Browser is the reference nearly every NetSuite developer has bookmarked. For SuiteQL it is the wrong book. Oracle’s replacement is the Records Catalog, which documents what is available through the analytics data source and, per Oracle, shows for each field whether it is available in SuiteAnalytics Workbook, SuiteScript, SuiteTalk REST web services and SuiteAnalytics Connect. Access is permission-gated: the Records Catalog is available to roles that have the Records Catalog permission assigned, granted under Setup > Users/Roles > Manage Roles.

Two NetSuite data sources and the reference each one is documented inSaved searches and N/search read the search-and-report data source documented in the Records Browser; SuiteQL and N/query read the analytics data source documented in the Records Catalog. Names may differ between the two.Two data sources, two name catalogsSaved searchN/searchSuiteQLN/query, REST suiteqlSearch and reportdata sourceAnalyticsdata sourceRecordsBrowserRecordsCatalognames may differ between the two — dashed path does not document the solid one

Treat the migration as a schema port, not a dialect port. The queries are the easy half; the names are the half that fails in production.

The timestamp that never moves when stock moves

Every incremental-sync guide reaches for a last-modified filter, and for most record types that is correct. For inventory it is the single most expensive mistake in this topic, because Oracle documents two different timestamps with two different triggers on the item.

From Oracle’s Inventory Items documentation: “Lastmodifieddate updates only when you update the item record.” And, on the same page: “Lastquantityavailablechange updates when you enter or edit an inventory-affecting transaction.”

Read those together and the failure mode is exact. Selling a unit does not update the item record — it creates a sales order and, later, an item fulfilment. Receiving stock does not update the item record either; it creates an item receipt. An inventory adjustment, a transfer, a return: none of them submit the item record. So a stock feed that asks “which items changed since my last run?” using lastmodifieddate gets an empty result set on a day when the warehouse shipped four hundred orders. The job completes. The exit code is zero. The row count is zero because nothing matched, not because nothing happened, and no monitor distinguishes those two states unless you built one.

This is the shape of defect that survives review for months: it fails closed, it fails quietly, and the only external symptom is a storefront whose quantities drift further from NetSuite every week. The items that do come through are the ones an admin happened to edit — a description change, a price update — which makes the feed look alive.

Which item timestamp moves for which eventEditing an item record moves lastmodifieddate. Inventory-affecting transactions such as fulfilments, receipts and adjustments move lastquantityavailablechange instead, so a sync filtered on lastmodifieddate misses every stock movement.Which timestamp movesItem record editedprice, description, custom fieldInventory-affectingtransactionitem fulfilmentitem receiptadjustment, transferlastmodifieddatelastquantityavailablechangea stock feed filtered on the dashed field matches nothing on a busy day

The correction is to filter on the quantity-change timestamp for anything stock-driven, and to keep the last-modified filter only for attribute-driven feeds — names, prices, categories, custom fields. They are two different feeds with two different cursors, and collapsing them into one query is what produces the bug. Resolve the exact record type and column name for the quantity-change timestamp in your own account’s Records Catalog rather than copying a table name from a guide; availability is scoped by role and by which analytics record types your account exposes, which is precisely the reason the Records Catalog exists.

The same class of trap sits underneath the sync-frequency argument. Tightening a schedule from fifteen minutes to one does nothing when the predicate is wrong, which is why our analysis of why cadence is not what causes oversells puts write semantics ahead of interval tuning. Cadence changes how often you ask; it does not change what you asked for.

There is no salesOrder table

The second name-level failure is structural rather than semantic. Integrators moving from saved searches expect a record type per transaction kind, because that is how the search UI presents them. The analytics data source does not work that way. Oracle’s Available Record Types documentation describes the transaction record type as standard and available through all NetSuite data sources, and describes reaching sales order data “through the transaction record type using Workbook”, where the Sales Order permission on your role is what gates access.

Oracle’s own SuiteQL syntax examples select FROM transaction. Selecting FROM salesOrder is not a variant spelling of that; it is a table that the query planner has to reject. The same applies to invoices, purchase orders, item receipts and every other transaction kind: they are rows in one table, separated by a type column, not tables of their own.

Two further habits travel across from saved searches and need dropping at the same time. Internal IDs are exposed as id in the analytics data source, not internalId. And saved-search status values of the form salesOrder:C are a search-layer encoding — they are not values you compare against in SuiteQL. Resolve the status representation for the transaction record type in the Records Catalog before writing a status predicate, because getting this wrong produces a query that runs and quietly filters out every row you wanted.

Choosing between REST web services and a RESTlet to carry the query is a separate decision with its own trade-offs, covered in our guide to picking the right NetSuite interface. The schema facts in this section apply identically to both.

Row limiting is TOP; limit and offset live in the URL

Oracle documents that “SuiteQL supports the syntax for both SQL-92 and Oracle SQL. However, you can’t use both syntaxes in the same query.” LIMIT ... OFFSET belongs to neither. It is MySQL and PostgreSQL syntax, and it does not appear anywhere in Oracle’s SuiteQL syntax reference. The row-limiting form Oracle actually publishes in its examples is SELECT TOP n, as in SELECT TOP 10 * FROM transaction.

Pagination over REST is not expressed in the query text at all. Oracle’s REST example puts it in the request URL: POST https://<account>.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql?limit=10&offset=10, with the SQL supplied in the body under the q key. A guide that shows LIMIT 1000 OFFSET {n} inside q has put the pagination in the one place the documented interface does not read it from.

There is a second problem with offset paging that survives even after you move the parameters to the right place. Offset paging over a result set ordered by a timestamp is only safe if the set is stable for the duration of the walk. During a catalog sync it is not: rows are being modified by the very transactions you are trying to capture, so a row can shift across a page boundary between request three and request four and be skipped entirely, or be returned twice. Cursor paging — carry the last timestamp and the last id forward, and ask for rows strictly after that pair — has no such window, and it restarts cleanly after a failure without re-walking from zero.

Three row ceilings, three interfaces

Guides routinely quote a single row limit for SuiteQL. There is no single limit — the number depends on which interface you called it through, and mixing them up produces capacity plans that are wrong by more than an order of magnitude.

Interface Documented ceiling What to do past it
query.runSuiteQL (N/query, SuiteScript) “This method can return a maximum of 5000 results” Oracle’s own instruction: “If you need to return more results, use query.runSuiteQLPaged(options) instead”
REST web services suiteql “Using SuiteQL queries, you can return a maximum of 100,000 results” Split the work by a stable key range, or move bulk extraction to SuiteAnalytics Connect
Per-page size over REST Set with the limit query parameter on the request URL Page with offset, or prefer cursor paging on a timestamp plus id

Verdict: size the extraction against the ceiling of the interface you are actually calling, not against the largest number you have seen quoted. A scheduled script that pulls a full catalog through query.runSuiteQL hits 5,000 rows long before it hits anything else, and it does so without an error you would recognise as a truncation.

Governance is the other budget, and it is charged separately from the row count: Oracle lists query.runSuiteQL at 10 units per call. That is cheap per call and ruinous in a loop — a scheduled script has 10,000 units to spend on everything it does, so a thousand queries is the entire budget before a single record is written. This is the same arithmetic that governs every other integration surface, laid out in our guide to NetSuite governance and concurrency at production volume. One paged query beats a loop of small ones on both budgets at once.

Bound parameters replace string interpolation

Almost every published SuiteQL example builds the query by pasting values into a string: a timestamp, a list of SKUs, an email address, a customer ID. In an integration those values arrive from outside NetSuite — from a storefront webhook, a CSV, a queue message — and string concatenation is how that becomes an injection surface.

Oracle notes that SuiteQL “includes a list of supported SQL functions and doesn’t allow you to use unsupported SQL functions in your query, which prevents SQL injection and other unauthorized access to data”, and that SuiteQL “enforces the same role-based access restrictions used in SuiteAnalytics Workbook”. Those are real mitigations and they are not a substitute for parameterisation — a function allow-list does not stop an injected predicate from changing which rows you return.

Use parameters. In SuiteScript, N/query accepts a params array alongside the query text. Over REST, NetSuite 2026.2 added bound parameters to the SuiteQL endpoint, so the values travel separately from the statement rather than being pasted into it. As with every NetSuite release, the rollout is phased by account — check your account’s upgrade date in the Release Portlet before assuming the feature is live in production, and keep the parameterised form in code either way.

/**
 * @NApiVersion 2.1
 * @NScriptType MapReduceScript
 */
define(['N/query', 'N/runtime'], (query, runtime) => {

  // Attribute feed: lastmodifieddate is correct here, because these
  // changes DO submit the item record.
  const ATTRIBUTE_FEED = `
    SELECT TOP 1000 id, itemid, displayname, lastmodifieddate
    FROM item
    WHERE lastmodifieddate > ?
      AND isinactive = 'F'
    ORDER BY lastmodifieddate, id`;

  const getInputData = () => {
    const since = getCursor();            // ISO timestamp from your own state store
    return query.runSuiteQL({
      query:  ATTRIBUTE_FEED,
      params: [since]                     // bound, never concatenated
    }).asMappedResults();
  };

  const map = (context) => {
    const script = runtime.getCurrentScript();
    if (script.getRemainingUsage() < 100) {
      // Yield rather than die mid-batch with SSS_USAGE_LIMIT_EXCEEDED.
      throw error.create({ name: 'SX_YIELD', message: 'usage floor reached' });
    }
    const row = JSON.parse(context.value);
    pushToStorefront(row);                // idempotent by item id
  };

  return { getInputData, map };
});

Two details in that sample are not decoration. getRemainingUsage() is checked before work, not after, because a script that exhausts its budget dies immediately and commits whatever it had already written — a half-processed batch with no checkpoint. And the ordering is on lastmodifieddate, id rather than the timestamp alone, so the cursor is unique and a page boundary cannot land in the middle of a group of rows sharing a second.

A corrected incremental sync loop

Here is the REST form with the pagination in the documented place and the required header present. Note that the stock feed and the attribute feed are separate calls with separate cursors, which is the whole point of the section above.

POST /services/rest/query/v1/suiteql?limit=1000&offset=0 HTTP/1.1
Host: <account-id>.suitetalk.api.netsuite.com
Content-Type: application/json
Prefer: transient
Authorization: Bearer <oauth2-access-token>

{
  "q": "SELECT TOP 1000 id, itemid, lastmodifieddate FROM item WHERE lastmodifieddate > ? AND isinactive = 'F' ORDER BY lastmodifieddate, id",
  "params": ["2026-08-11T06:00:00Z"]
}

Prefer: transient is not optional — Oracle documents it as a required header parameter on this endpoint. Omitting it is a fast failure rather than a silent one, which makes it the friendliest mistake in this post.

The loop around that call has four rules, and the first one is the one most implementations get wrong:

// Cursor paging. The cursor is (timestamp, id) — never a row offset.
let cursor = loadCursor();          // { ts, id } from durable state
let advanced = false;

while (true) {
  const page = await suiteql(PAGE_QUERY, [cursor.ts, cursor.ts, cursor.id]);
  if (page.items.length === 0) break;

  for (const row of page.items) {
    await applyToStorefront(row);   // idempotent on row.id
  }

  const last = page.items[page.items.length - 1];
  cursor = { ts: last.lastmodifieddate, id: last.id };
  saveCursor(cursor);               // commit AFTER the page is applied
  advanced = true;

  if (page.items.length < PAGE_SIZE) break;
}

// A run that matched nothing is a fact worth emitting, not a silent success.
emitMetric('sync.rows_applied', advanced ? 'nonzero' : 'zero');

Commit the cursor after the page is applied, not when it is fetched — otherwise a crash between fetch and write skips a page permanently. Make the storefront write idempotent on the record id, because at-least-once delivery is the only guarantee this loop offers. Break on a short page rather than on a fixed iteration count. And emit a metric when a run matches zero rows, because that is the signal that separates “nothing changed” from “the predicate is wrong” — the distinction the lastmodifieddate bug hides behind. Which metrics are worth alerting on and which are noise is covered in our guide to NetSuite integration observability.

SuiteQL is the correct interface for machine-to-machine extraction, but “replace all saved searches” is the wrong instruction. The decision is mechanical.

Situation Use Why
Joins beyond record relationships, subqueries, aggregation SuiteQL The search layer cannot express them
Result feeds integration code SuiteQL via N/query or REST Returns rows, not instantiated record objects
A business user must own and edit the query Saved search UI editing, sharing and scheduling are built in
Scheduled email export with no code Saved search Native scheduling and delivery

Verdict: SuiteQL for anything code reads; saved searches for anything a person maintains. The operational argument for SuiteQL in integrations is not raw speed — it is that the query lives in your repository, gets reviewed, and cannot be altered by someone editing a search in the UI who has no idea an integration depends on it. That is a change-control property, and it is worth more than a query-plan argument.

Migration audit

Work down this list against a live account before cutting a feed over. Items are ordered by execution, and each one is checkable rather than aspirational.

  • Confirm the Records Catalog permission is assigned to the role your integration authenticates as, under Setup > Users/Roles > Manage Roles.
  • Resolve every record type and column name in your queries against the Records Catalog, not the SuiteScript Records Browser.
  • Replace every transaction-kind table name with the transaction record type plus an explicit type predicate.
  • Replace internalId with id throughout, and re-resolve any status comparison against its analytics representation.
  • Split stock-driven feeds from attribute-driven feeds, and give each its own cursor and its own timestamp column.
  • Verify the stock feed’s predicate by making an inventory-affecting transaction in sandbox and confirming the row appears — an empty run proves nothing.
  • Move limit and offset out of the query text and onto the request URL, then replace offset paging with a timestamp-plus-id cursor.
  • Convert every interpolated value to a bound parameter; confirm the 2026.2 rollout has reached the account before relying on REST bound parameters in production.
  • Add Prefer: transient to every REST SuiteQL request.
  • Add a remaining-usage guard to every script that queries in a loop, and size the batch against the 5,000-row ceiling of query.runSuiteQL.
  • Emit a zero-rows metric per run and alert on a sustained zero, not only on errors.

What this changes for a WooCommerce sync

A WooCommerce storefront reading stock from NetSuite has one job that matters more than any other: do not display a quantity the warehouse cannot honour. Every defect in this post attacks that job from a different side. The wrong timestamp means the storefront never learns that stock moved. The wrong table name means the feed fails loudly, which is at least honest. Offset paging over a shifting result set means individual SKUs are skipped at random — the hardest of the three to reproduce, because the skipped SKU differs every run.

The remedies are unglamorous and they are all cheap relative to the alternative. Separate the feeds. Bind the parameters. Page by cursor. Check the predicate against a real transaction in sandbox rather than trusting that a clean log means a working sync. And write the storefront side to be idempotent on the NetSuite record id, so a replayed page costs nothing.

If you are building this rather than buying it, the surrounding decisions — which interface carries the traffic, how the queue handles retries, where the mapping table lives — are covered across the NetSuite and WooCommerce integration guide library. If you would rather not own the cursor logic, our NetSuite Integration Pro ships the batched, resumable version of exactly this loop.

Get the working checklists

The runbooks and decision checklists from these guides, as printable PDFs — free in the SoftXone guide library.

Browse the guide library →

Sources & Further Reading

References

  1. Inventory Items — Oracle NetSuite Applications SuiteSource of both quoted sentences on field update timing: lastmodifieddate updates only on item-record updates; lastquantityavailablechange updates on inventory-affecting transactions.
  2. SuiteScript 2.1 Analytic APIs — Oracle NetSuite Applications SuiteStates that the analytics data source differs from the search-and-report data source, and that the SuiteScript Records Browser is not useful for finding analytic-API names.
  3. Executing SuiteQL Queries Through REST Web Services — Oracle NetSuite Applications SuiteEndpoint reference: the required Prefer: transient header, the limit and offset URL query parameters, and the 100,000-result maximum.
  4. query.runSuiteQL(options) — Oracle NetSuite Applications SuiteDocuments the 5,000-result maximum, the instruction to use runSuiteQLPaged beyond it, and the 10-unit governance cost.
  5. SuiteQL — Oracle NetSuite Applications SuiteStates that SuiteQL supports SQL-92 and Oracle SQL syntax but not both in one query, and that it enforces SuiteAnalytics Workbook role-based access restrictions.
  6. SuiteQL Syntax and Examples — Oracle NetSuite Applications SuiteOracle’s published examples, including the SELECT TOP n row-limiting form and selects against the transaction table.
  7. Available Record Types — Oracle NetSuite Applications SuiteDescribes the transaction record type as standard across data sources and sales order data as reached through it, gated by the Sales Order permission.
  8. Finding Record Type and Field Names — Oracle NetSuite Applications SuiteOracle’s documented methods for resolving SuiteQL names, including the Records Catalog and the SuiteAnalytics Workbook UI.

Frequently asked questions

Can I reuse my saved-search field names in SuiteQL?

Not reliably. SuiteQL reads the analytics data source and Oracle states the record types and fields there may differ from the search-and-report data source that saved searches and N/search use. Resolve every name in the Records Catalog instead of the SuiteScript Records Browser. The Records Catalog is permission-gated: the role your integration authenticates as needs the Records Catalog permission, assigned under Setup > Users/Roles > Manage Roles, so a developer who can see it in their own admin role may still find the integration role cannot.

Why does my incremental inventory sync return zero rows?

Almost always because it filters on lastmodifieddate. Oracle documents that field as updating only when you update the item record, while lastquantityavailablechange updates when you enter or edit an inventory-affecting transaction. Fulfilments, receipts, adjustments and transfers do not submit the item record, so the filter matches nothing on a busy day. The operational fix beyond the query itself is to emit a metric on a zero-row run and alert on a sustained zero, because this defect never produces an error.

Can I use LIMIT and OFFSET inside a SuiteQL query?

Oracle documents that SuiteQL supports SQL-92 and Oracle SQL syntax but not both in the same query, and LIMIT belongs to neither dialect. The row-limiting form in Oracle published examples is SELECT TOP n. For REST, limit and offset are query parameters on the request URL rather than clauses inside the q body value. Even placed correctly, offset paging over a result set ordered by a timestamp can skip or duplicate rows while that set is still being modified; carry a timestamp plus id cursor instead.

How many rows can one SuiteQL call return?

It depends which interface you called. Oracle documents query.runSuiteQL as returning a maximum of 5000 results and instructs you to use query.runSuiteQLPaged beyond that, while a SuiteQL query through REST web services can return a maximum of 100,000 results. Row count is also not the only budget: query.runSuiteQL costs 10 governance units per call, so a scheduled script with 10,000 units spends its entire budget on a thousand queries before writing anything.

Should I delete my saved searches after moving to SuiteQL?

No. Keep saved searches for anything a business user owns, because UI editing, sharing and native scheduled email delivery come free there and cost code in SuiteQL. The durable argument for SuiteQL in an integration is not query speed, it is change control: the query lives in your repository, goes through review, and cannot be altered by someone editing a search in the NetSuite UI who has no idea a nightly feed depends on it.

Related guides

Discussion

Leave a Reply

Your email address will not be published. Required fields are marked *


Ship it

Need this in your stack?

We build, integrate, and ship — no calls, just delivery.

Start a project →