Skip to content
NetSuite Reference

The NetSuite Quirks Guide

NetSuite reads like a normal relational database right up until it doesn’t. Every document type shares one table family, the partition column sits on the line rather than the header, booleans are strings, ids are not labels, accounting lines repeat once per book, and deletes leave no trace in the column you built your incremental load on. This guide is the shortcut past every one of those, with copy-ready Databricks SQL. Last verified August 2026.

One door out: the NetSuite2.com source

There is exactly one analytics surface: NetSuite2.com. Two clients read it — SuiteAnalytics Connect (the ODBC / JDBC / ADO.NET drivers, and the bulk path) and SuiteQL (based on SQL-92 (opens in new tab) in-account or over the REST query service, which caps a response at 100,000 rows (opens in new tab) and pages with limit / offset). The legacy NetSuite.com data source is removed as of 2026.1 (opens in new tab), so the two-sources era is over — everything below assumes the surviving one.

Access is role-based and mirrors the UI (opens in new tab): a Connect session sees what the role it authenticated as would see on screen, so an extract that comes back short is a permissions question before it is a SQL question. The same role-considerations page (opens in new tab) spells out the restriction that surprises new implementations most — the Administrator role cannot be used for Connect access except through OAuth 2.0, so bulk extraction runs under a purpose-built integration role.

Two metadata records tell you what your own credentials can actually reach: oa_tables (opens in new tab) and oa_columns. The in-account Records Catalog is the live authority for record and field definitions, but it sits behind a login — which is why every citation on this site resolves without one, and why the schema axis here is pinned to the last public Analytics Browser.

4 parameters not filled: <catalog>, <schema>, <type>, <watermark>

-- Everything on this site reads the LANDED copy of a record — the Delta
-- output of a SuiteAnalytics Connect (or SuiteQL) extract in your own
-- lakehouse. No snippet here addresses live NetSuite.
SELECT
  t.id,
  t.tranid,
  t.trandate,
  t.entity,
  t.foreigntotal
FROM <catalog>.<schema>.transaction t
WHERE
  1 = 1
  -- AND t.type = '<type>'  -- discover values in-account: SELECT DISTINCT type
  -- AND t.lastmodifieddate >= TIMESTAMP '<watermark>'  -- inserts and updates only — deletes never appear here; diff deletedrecord, which is itself blind to line-level deletes (see quirks #deletes)
ORDER BY t.trandate;

-- The delivered ways analytics data leaves NetSuite, all reading the SAME
-- NetSuite2.com source:
--   SuiteAnalytics Connect - ODBC / JDBC / ADO.NET drivers; the bulk path
--   SuiteQL                - SQL-92 in-account (N/query) or over REST at
--                            /services/rest/query/v1/suiteql, which caps a
--                            response at 100,000 rows and pages with
--                            limit / offset
--   Records Catalog        - live metadata, in-account only (it needs a
--                            NetSuite login, so nothing on this site cites it)
-- The legacy NetSuite.com data source is REMOVED as of 2026.1 - NetSuite2.com
-- is the one door.

-- Connect metadata records list what your credentials can actually see:
SELECT * FROM oa_tables;   -- one row per exposed record
SELECT * FROM oa_columns;  -- one row per exposed column

Back to top ↑

OneWorld: subsidiary scoping and consolidation rates

In a OneWorld account the subsidiary is the partition that decides which rows belong to whom — and the column lives on transactionline, not on the transaction header. The header record carries no subsidiarycolumn at all, so “filter the orders for this legal entity” is always a line-level filter, even when the question is a header-level one.

The same anchor applies wherever the column is a scalar id — but on four records it is not. location, department, classification and item each carry subsidiary as a multiselectrather than a scalar foreign key, because a site, a segment or an item can serve several subsidiaries at once. An equality filter against a multi-valued column silently drops every shared row, so this reference emits no subsidiary anchor on those four: read the table notes and join through the record’s subsidiary map instead.

Currency is where OneWorld reporting goes wrong quietly. There are two rate records and they are not interchangeable: currencyrate is the dated transaction rate (one row per currency pair per effective date), while consolidatedexchangerate is what consolidation uses — one row per accounting book, posting period and from/to subsidiary pair, carrying currentrate, averagerate and historicalrate as three columns side by side. Pick the flavor the measure calls for; never average them, and never substitute the daily rate for the period one.

5 parameters not filled: <catalog>, <schema>, <subsidiary_id>, <location_id>, <DATE_TO>

-- OneWorld partitions by SUBSIDIARY - and the column is on the LINE, not on
-- the transaction header. The header record has no subsidiary column at all.
SELECT
  tl.transaction,
  tl.subsidiary,
  tl.item,
  tl.quantity,
  tl.netamount
FROM <catalog>.<schema>.transactionline tl
WHERE
  tl.subsidiary = <subsidiary_id>  -- OneWorld partition — see quirks guide #oneworld
  AND tl.mainline = 'F'  -- line grain; 'T' is the document header row — see quirks guide #transaction-spine
;

-- Anything that carries the column anchors it the same way, records with a
-- location column included:
SELECT
  iil.item,
  iil.location,
  iil.quantityonhand
FROM <catalog>.<schema>.inventoryitemlocations iil
WHERE
  iil.location = <location_id>  -- location scope — see quirks guide #oneworld
;

-- Two rate records, two jobs. currencyrate is the DAILY transaction rate,
-- one row per currency pair per effective date:
SELECT
  cr.basecurrency,
  cr.transactioncurrency,
  cr.effectivedate,
  cr.exchangerate
FROM <catalog>.<schema>.currencyrate cr
WHERE cr.effectivedate <= DATE '<DATE_TO>';

-- consolidatedexchangerate is what CONSOLIDATION uses: one row per accounting
-- book + posting period + from/to subsidiary pair, carrying three rate
-- flavors as three COLUMNS. Pick one deliberately per measure - they are not
-- interchangeable, and averaging them is never right.
SELECT
  cer.accountingbook,
  cer.postingperiod,
  cer.fromsubsidiary,
  cer.tosubsidiary,
  cer.currentrate,      -- balance-sheet style restatement
  cer.averagerate,      -- period-average style restatement
  cer.historicalrate    -- historical/equity style restatement
FROM <catalog>.<schema>.consolidatedexchangerate cer;

Back to top ↑

One table family holds every document

This is the structural headline. A sales order, a purchase order, a fulfillment, a receipt, an invoice and a journal are not six tables — they are rows in transaction and transactionline, told apart by the typecolumn. Every table page in this reference names the business records that resolve into it, because the record you’re asked about and the table you query are rarely the same word.

No type values appear anywhere on this site. Oracle publishes no enumeration of them in any public source we could verify — not the Analytics Browser, not the Connect browser, not the Help Center — so rather than guess, the honest instruction is to run SELECT DISTINCT type in your own account once and keep the result as your own mapping. A code copied from a blog post is the kind of thing that quietly filters out a whole document type.

The second trap is grain. Every document carries a header row among its lines, flagged by mainline: Oracle’s own SuiteQL examples filter mainline = 'T' (opens in new tab) to get header grain, and line-grain analytics take the inverse. Forget it and every document is counted twice — once as itself and once as its own summary line.

Documents also chain rather than nest: the line records where it came from in createdfrom, and Oracle documents the same linkage as transaction links (opens in new tab). That’s how a fulfillment ties back to its order, and it is the edge this reference draws as a document link — but it is only one of three mechanisms, and the header-grain one at that. Which to use when is three ways one document points at another.

4 parameters not filled: <catalog>, <schema>, <subsidiary_id>, <type>

-- Every transactional document lives in ONE table family. A sales order, a
-- purchase order, a fulfillment, a receipt and a journal are all rows in
-- transaction / transactionline, told apart by the type column.
--
-- Oracle publishes no list of the type values, so this reference ships none.
-- Read them from your own account once and keep them in your own mapping:
SELECT DISTINCT t.type
FROM <catalog>.<schema>.transaction t;

-- Header grain: Oracle's own SuiteQL examples filter the header row with
-- mainline = 'T'. Line-grain analytics take the inverse.
SELECT
  t.id,
  t.tranid,
  t.trandate,
  t.postingperiod,
  tl.item,
  tl.quantity,
  tl.netamount
FROM <catalog>.<schema>.transaction t
JOIN <catalog>.<schema>.transactionline tl
  ON tl.transaction = t.id
WHERE
  tl.subsidiary = <subsidiary_id>  -- OneWorld partition — see quirks guide #oneworld
  AND tl.mainline = 'F'  -- line grain; 'T' is the document header row — see quirks guide #transaction-spine
  -- AND t.type = '<type>'  -- discover values in-account: SELECT DISTINCT type
;

-- Documents chain to each other rather than nesting: the line records where
-- it came from, so a fulfillment line points back at its order line.
SELECT
  tl.transaction,
  tl.uniquekey,
  tl.createdfrom     -- the upstream transaction (internal id)
FROM <catalog>.<schema>.transactionline tl
WHERE tl.createdfrom IS NOT NULL;

Back to top ↑

Statuses are real, their values aren't published

status is a real, populated column — on the transaction header, and on the fulfillment and pick records this reference catalogs as well. What does not exist is a published list of the values it holds. Oracle enumerates them nowhere we could verify publicly, which is the same finding as the type discriminator, so this reference ships neither.

The consequence that costs real time: statuses are type-scoped. The same stored value under two different document types is not the same state, so a filter written for one document type quietly means something else applied to another. Never compare a status across types, and never filter on one without pinning the type beside it.

The workable pattern is to land status as data and build your own decode dimension: read the real type-and-status pairs out of your own account, curate the labels against what the UI shows for each pair, and join it back the way you decode every other coded column here (see internal ids, not display text). Treat that table as code rather than as a lookup you can copy between accounts — configuration adds states, and a stale label is worse than a raw value because it looks answered.

3 parameters not filled: <catalog>, <schema>, <type>

-- Status is a real, populated column - on the transaction header, and on the
-- fulfillment and pick records too. What does not exist is a published list of
-- its VALUES: Oracle enumerates them nowhere public, exactly as with the type
-- discriminator, so this reference ships none of either.

-- Statuses are TYPE-SCOPED. The same stored value under two document types is
-- not the same state, so never compare a status across types and never filter
-- one without pinning the type beside it. Read the real pairs from your own
-- account:
SELECT DISTINCT
  t.type,
  t.status
FROM <catalog>.<schema>.transaction t
ORDER BY t.type, t.status;

-- Then build your OWN decode dimension from that result and curate the labels
-- against what your account's UI shows for each pair:
CREATE OR REPLACE TABLE <catalog>.<schema>.transaction_status_decode AS
SELECT DISTINCT
  t.type                AS transaction_type,
  t.status              AS status_value,
  CAST(NULL AS STRING)  AS status_label   -- fill in from your account's UI
FROM <catalog>.<schema>.transaction t;

-- And decode by joining it, the way every other coded column in this source is
-- decoded:
SELECT
  t.id,
  t.tranid,
  t.trandate,
  d.status_label
FROM <catalog>.<schema>.transaction t
LEFT JOIN <catalog>.<schema>.transaction_status_decode d
  ON  d.transaction_type = t.type
  AND d.status_value     = t.status
WHERE
  1 = 1
  -- AND t.type = '<type>'  -- discover values in-account: SELECT DISTINCT type
;

-- Treat that decode table as CODE, not as a lookup you can copy between
-- accounts: a customer configuration adds states, and a stale label is worse
-- than a raw value because it looks answered.

Back to top ↑

Accounting lines duplicate per accounting book

transactionaccountingline has no id of its own. Its key is the transaction, the transaction line, and the accountingbook — which means that with multi-book accounting enabled, the accounting detail for one line exists once per book, up to five. Sum it without a book anchor and every amount is a multiple of the truth.

Which book to anchor is a stored flag, not a constant: read isprimary from accountingbook rather than hard-coding an id that differs between accounts and sandboxes.

Currency discipline travels with it. The line carries transaction-currency amounts (foreignamount, creditforeignamount, debitforeignamount); the accounting line carries the subsidiary’s base currency (amount, credit, debit, netamount). Adding them together produces a number nobody can tie out to either ledger.

And never join the header straight to the accounting line: one header has many lines, so the join fans the accounting detail across all of them. The only supported path is transactiontransactionline transactionaccountingline, and this reference enforces it — no direct edge between the header and the accounting line exists in the catalog, by test.

4 parameters not filled: <catalog>, <schema>, <accounting_book_id>, <subsidiary_id>

-- transactionaccountingline (TAL) has NO id of its own. Its key is the
-- transaction, the transaction line, and the ACCOUNTING BOOK - so with
-- multi-book accounting on, every line's accounting detail repeats once per
-- book (up to five). Without a book anchor, every amount multiplies.
SELECT
  tal.transaction,
  tal.transactionline,
  tal.accountingbook,
  tal.account,
  tal.amount,       -- subsidiary BASE currency
  tal.credit,
  tal.debit,
  tal.netamount
FROM <catalog>.<schema>.transactionline tl
JOIN <catalog>.<schema>.transactionaccountingline tal
  ON  tal.transaction     = tl.transaction
  AND tal.transactionline = tl.id
  AND tal.accountingbook = <accounting_book_id>
WHERE
  tl.subsidiary = <subsidiary_id>  -- OneWorld partition — see quirks guide #oneworld
  AND tl.mainline = 'F'  -- line grain; 'T' is the document header row — see quirks guide #transaction-spine
;
-- TAL duplicates once per accounting book (max 5) and is stated in the subsidiary's base currency; TL is transaction currency — see quirks #tal-and-books

-- Which book is the primary one is a stored flag, not a constant - read it
-- rather than hard-coding an id:
SELECT ab.id, ab.name, ab.isprimary
FROM <catalog>.<schema>.accountingbook ab;

-- Currency discipline: the LINE carries transaction-currency amounts
-- (foreignamount, creditforeignamount, debitforeignamount); TAL carries the
-- subsidiary's base currency. Mixing them in one SUM is the quiet way to
-- produce a number nobody can tie out.
--
-- And never join the HEADER straight to TAL: one header has many lines, so a
-- direct join fans the accounting detail out across all of them. The only
-- supported path is transaction -> transactionline -> transactionaccountingline,
-- which is why this reference draws no such edge.

Back to top ↑

Booleans arrive as 'T' and 'F' strings

Check-box columns come back from Connect as VARCHAR 'T' / 'F' strings, not as a boolean type. In most client languages both values are truthy, so a naive test passes for every row and the filter you thought you wrote does nothing.

Compare explicitly (= 'T'), or cast once at the bronze-to-silver boundary so downstream models get a real boolean and nobody has to remember the convention. Every field listing in this reference flags these columns with a T/F chip, and the generated boilerplate SQL carries the same decode comment.

2 parameters not filled: <catalog>, <schema>

-- Connect returns check-box columns as VARCHAR 'T' / 'F' strings, never as a
-- boolean type. Both values are truthy in most client languages, so a naive
-- test passes for every row.
SELECT
  tl.transaction,
  tl.mainline,     -- 'T'/'F' string — compare = 'T', or CAST via CASE; see quirks #tf-booleans
  tl.isclosed,
  tl.taxline
FROM <catalog>.<schema>.transactionline tl
WHERE tl.mainline = 'F';

-- Cast once, at the bronze-to-silver boundary, so downstream models get a
-- real boolean and nobody has to remember the convention:
CREATE OR REPLACE VIEW <catalog>.<schema>.transaction_line_silver AS
SELECT
  tl.transaction,
  tl.uniquekey,
  CASE tl.mainline  WHEN 'T' THEN TRUE WHEN 'F' THEN FALSE END AS is_header_row,
  CASE tl.isclosed  WHEN 'T' THEN TRUE WHEN 'F' THEN FALSE END AS is_closed,
  tl.item,
  tl.quantity
FROM <catalog>.<schema>.transactionline tl;

-- The Fields table on every record page flags these columns with a T/F chip,
-- and the generated boilerplate SQL carries the same decode comment.

Back to top ↑

Internal ids, not display text

Select-type columns hold numeric internal ids. The customer on a transaction is an id, the item on a line is an id, the currency is an id — none of them are the text the UI shows.

NetSuite’s own display-value function resolves that text at extraction time: it is evaluated by NetSuite while the query runs there, and it does not exist in Databricks. In the lakehouse you decode by joining the landed list records — which is why the practical rule is to land the records you decode against on the same cadence as the facts, or your labels drift behind your ids.

One naming trap is worth memorizing: on the currency record the ISO code lives in symbol, and displaysymbol holds the glyph. Join on the wrong one and you get a column full of dollar signs.

3 parameters not filled: <catalog>, <schema>, <watermark>

-- Select-type columns hold NUMERIC INTERNAL IDS, not the text the UI shows.
-- t.entity is an id; tl.item is an id; t.location is an id.
SELECT
  t.id,
  t.entity,     -- internal id, not a customer name
  t.location,   -- internal id, not a warehouse name
  t.currency    -- internal id, not 'USD'
FROM <catalog>.<schema>.transaction t;

-- BUILTIN.DF() resolves a display value - but it is an EXTRACTION-TIME
-- device, evaluated by NetSuite while the query runs there. It does not exist
-- in Databricks, so in the lakehouse you decode by joining the landed records:
SELECT
  t.id,
  t.tranid,
  c.companyname   AS customer_name,
  l.name          AS location_name,
  cur.symbol      AS currency_iso_code
FROM <catalog>.<schema>.transaction t
LEFT JOIN <catalog>.<schema>.customer c ON c.id = t.entity
LEFT JOIN <catalog>.<schema>.location l ON l.id = t.location
LEFT JOIN <catalog>.<schema>.currency cur ON cur.id = t.currency
WHERE
  1 = 1
  -- AND t.lastmodifieddate >= TIMESTAMP '<watermark>'  -- inserts and updates only — deletes never appear here; diff deletedrecord, which is itself blind to line-level deletes (see quirks #deletes)
;

-- One trap worth memorizing on the currency record: the ISO code lives in
-- symbol, and displaysymbol holds the glyph. Joining on the wrong one gives
-- you a column full of dollar signs.

-- The practical rule: land the list records you decode against (customer,
-- vendor, item, location, subsidiary, currency, accountingperiod) in the same
-- extract cadence as the facts, or your labels drift behind your ids.

Back to top ↑

Custom fields and custom records

Customization is how most NetSuite accounts are configured, and it shows up in the schema by prefix. Oracle documents each family on its own field-definition page: custbody_ (opens in new tab) for a transaction body column, custcol_ (opens in new tab) for a transaction line column, custentity_ (opens in new tab) for an entity record, and custitem_ (opens in new tab) for an item record.

Custom record types become tables in their own right, named customrecord_ plus the id given at creation (opens in new tab). Once landed they behave like any other record — but the name is account-specific, so a pipeline written against one account’s custom tables will not run against another’s.

Nothing prefixed this way is cataloged here, and it cannot be: this reference documents the stock records every account shares. For yours, the in-account Records Catalog is the authority. Build the expectation in early — treat custom columns as a discovery step in every implementation rather than an exception to handle later.

4 parameters not filled: <your_field>, <catalog>, <schema>, <your_record_id>

-- Customization is not an edge case in NetSuite - it is how most accounts
-- are configured, and it shows up in the schema by PREFIX.
--
--   custbody_    custom column on a transaction BODY
--   custcol_     custom column on a transaction LINE
--   custentity_  custom column on an entity record (customer, vendor, ...)
--   custitem_    custom column on an item record
--
-- These columns exist in one account and not the next, so nothing prefixed
-- this way is cataloged here - your account's Records Catalog is the only
-- authority for yours.
SELECT
  t.id,
  t.tranid
  -- , t.custbody_<your_field>   -- account-specific; confirm before relying on it
FROM <catalog>.<schema>.transaction t;

-- Custom RECORD types become tables of their own, named customrecord_ plus
-- the id given at creation. Treat them exactly like stock records once
-- landed - and expect their names to differ account to account:
--   SELECT * FROM customrecord_<your_record_id>;

-- There is no query that lists your account's custom records. The in-account
-- Records Catalog is the discovery authority - nothing in the landed copy
-- enumerates them. The deletion ledger is not a discovery mechanism: it only
-- ever sees records that were DELETED. What it does give you is a flag, so a
-- pipeline reconciling deletes can tell a custom record apart from a stock
-- one instead of failing on an unknown name:
SELECT
  dr.recordtypeid,
  dr.name,
  dr.iscustomrecord   -- 'T' when the DELETED record was a custom one
FROM <catalog>.<schema>.deletedrecord dr;

Back to top ↑

One item table, many item types

item is a single wide record covering every item type — inventory, non-inventory, service, kit, assembly, group — discriminated by itemtype and subtype. Most of its columns are meaningful for some types and null for the rest, which makes an unfiltered aggregate over any numeric column an average of things that aren’t comparable. Filter the type before you aggregate, every time.

The catalog does list per-type record pages — inventoryitem, assemblyitem, kititem, serviceitem, itemgroup, noninventoryitem — but they are join-only stubs: no fields table, nothing to extract. That is why this reference catalogs the unified master and the generic bridges instead of a table per type, the same verdict the item-to-subsidiary bridge carries.

The satellite this reference ships in place of the per-type clones is itemmember: the component lines behind a parent item, with a quantity, a component yield and an effective/obsolete revision window. Per-type member records exist too — one each for kits, item groups and assemblies — and are deliberately left out for the same reason the per-type bridges are. Both of its pointers — the component and the parent — resolve to the same item master, so a readable component list joins item twice under two aliases. And memberunit is text rather than a unit id, so read it as a label; the conversion arithmetic lives on unitstypeuom.

2 parameters not filled: <catalog>, <schema>

-- One item record covers every item type - inventory, non-inventory, service,
-- kit, assembly, group - and itemtype is the discriminator. Most columns are
-- meaningful for some types and null for the rest.
SELECT DISTINCT
  i.itemtype,
  i.subtype
FROM <catalog>.<schema>.item i;

-- So pin the type BEFORE aggregating anything numeric. A costing method means
-- one thing on an assembly and another on a service item, and a column that is
-- null for half the types averages to a number with no referent:
SELECT
  i.itemtype,
  COUNT(*)                      AS item_count,
  COUNT(i.costingmethod)        AS with_costing_method
FROM <catalog>.<schema>.item i
WHERE i.isinactive = 'F'   -- 'T'/'F' string — compare = 'T', or CAST via CASE; see quirks #tf-booleans
GROUP BY i.itemtype;

-- The generic member record this catalog ships in place of the per-type
-- clones: itemmember, the component lines behind a parent item. Both of its
-- item pointers hit the SAME master, so a readable component list joins item
-- twice under two aliases:
SELECT
  parent.itemid     AS parent_item,
  parent.itemtype   AS parent_item_type,
  comp.itemid       AS component_item,
  im.quantity,
  im.bomquantity,
  im.memberunit,    -- TEXT, not a unit id - it cannot be joined
  im.componentyield,
  im.effectivedate
FROM <catalog>.<schema>.itemmember im
JOIN <catalog>.<schema>.item parent ON parent.id = im.parentitem
JOIN <catalog>.<schema>.item comp   ON comp.id   = im.item
WHERE im.obsoletedate IS NULL
ORDER BY parent.itemid, im.linenumber;

-- What NOT to look for: a table per item type. The analytics catalog does list
-- per-type record pages, but they carry no fields of their own - they exist to
-- hold joins - so there is nothing to extract from them. The unified master
-- plus the generic bridges is the whole model.

Back to top ↑

Work orders are spine rows, not a manufacturing table

Look for a work-order table and you will not find one. The work order, the issue of components against it, the completion, the assembly build and unbuild, and the close are all documents — which in NetSuite means they are type values on transaction and transactionline, the same spine a sales order lives on. The record map on this site names each of them and answers with the same table every time. As with every other document, Oracle publishes no list of the type values, so read them from your own account once with a SELECT DISTINCT on the type column and keep the mapping yourself.

The consequence is the useful part: work-in-process analysis is a spine query. Component consumption and finished-goods receipt post as inventory-affecting lines, so material usage, scrap and output all come from transactionline joined to item — not from the bill of materials, which is the plan rather than the record of what happened.

What is a real record is the master data. bom names a bill of materials, bomrevision makes it date-effective, bomrevisioncomponent holds the component lines, and manufacturingrouting with manufacturingroutingroutingstepholds the operations and their rates. Because the revisions are dated, an as-of explosion picks the revision in force on the date you are reporting; taking the highest revision id restates history at today’s design.

One record bridges the two halves: manufacturingoperationtask. Its workorder column holds a transaction internal id, so it is the join that makes a standard-versus-actual read possible — runrate and setuptime are what the routing said, actualruntime and actualsetuptime are what the floor recorded. Compare them per step rather than averaging across steps of different lengths. And do not expect the spine to hand you a valued WIP number: what a build cost is a costing output, the same caveat #costing carries for on-hand value.

6 parameters not filled: <catalog>, <schema>, <subsidiary_id>, <type>, <watermark>, <DATE_TO>

-- Manufacturing has master data of its own, but the DOCUMENTS are spine rows.
-- The work order, the issue of components, the completion, the assembly build
-- and unbuild, the close - every one of them is a type value on
-- transaction / transactionline, exactly like a sales order.
--
-- Oracle publishes no list of those type values either, so read them once from
-- your own account and keep the mapping yourself:
SELECT DISTINCT t.type
FROM <catalog>.<schema>.transaction t;

-- Which means WIP analysis is a SPINE query. Component consumption and
-- finished-goods receipt are inventory-affecting lines like any other, so
-- material usage comes from the line record joined to the item master - not
-- from the BOM, which is the plan rather than what happened:
SELECT
  t.tranid,
  t.trandate,
  i.itemid,
  tl.quantity,
  tl.location
FROM <catalog>.<schema>.transaction t
JOIN <catalog>.<schema>.transactionline tl
  ON tl.transaction = t.id
JOIN <catalog>.<schema>.item i
  ON i.id = tl.item
WHERE
  tl.subsidiary = <subsidiary_id>  -- OneWorld partition — see quirks guide #oneworld
  AND tl.mainline = 'F'  -- line grain; 'T' is the document header row — see quirks guide #transaction-spine
  -- AND t.type = '<type>'  -- discover values in-account: SELECT DISTINCT type
  -- AND t.lastmodifieddate >= TIMESTAMP '<watermark>'  -- inserts and updates only — deletes never appear here; diff deletedrecord, which is itself blind to line-level deletes (see quirks #deletes)
;

-- The one record that points back at the order: manufacturingoperationtask.
-- Its workorder column holds a transaction internal id, which is what makes a
-- standard-versus-actual read possible at all.
  -- no lastmodifieddate on this record — there is no watermark column, so incremental extraction must full-refresh it (see quirks #deletes)
SELECT
  t.tranid,
  mot.operationsequence,
  mot.title,
  mot.inputquantity,
  mot.completedquantity,
  mot.setuptime      AS standard_setup_minutes,
  mot.actualsetuptime,
  mot.runrate        AS standard_minutes_per_unit,
  mot.actualruntime
FROM <catalog>.<schema>.manufacturingoperationtask mot
JOIN <catalog>.<schema>.transaction t
  ON t.id = mot.workorder
ORDER BY t.tranid, mot.operationsequence;

-- The master data behind the order IS a set of real records - and it is
-- date-effective, so pick the revision in force rather than the newest one:
SELECT
  b.name           AS bom_name,
  br.name          AS revision_name,
  comp.itemid      AS component_item,
  brc.quantity,
  brc.componentyield
FROM <catalog>.<schema>.bom b
JOIN <catalog>.<schema>.bomrevision br
  ON br.billofmaterials = b.id
JOIN <catalog>.<schema>.bomrevisioncomponent brc
  ON brc.bomrevision = br.id
JOIN <catalog>.<schema>.item comp
  ON comp.id = brc.item
-- The revision currently in force is normally OPEN-ENDED, so a plain BETWEEN
-- on the two dates drops exactly the revision you usually want:
WHERE br.effectivestartdate <= DATE '<DATE_TO>'
  AND (br.effectiveenddate IS NULL OR br.effectiveenddate >= DATE '<DATE_TO>')
ORDER BY b.name, comp.itemid;

Back to top ↑

Deletes never reach your watermark

lastmodifieddate is the incremental watermark, and it catches inserts and updates — nothing else. A deleted row simply stops existing; it never appears in a watermark window. A watermark-only pipeline therefore keeps deleted documents forever, and the gap widens silently for as long as nobody reconciles a count.

The half-fix is the deleted-record ledger (opens in new tab): deletedrecord lists what went away, with the record type, the name, the script id, the timestamp and who did it. Read that list carefully, because of what is missing from it — the analytics record publishes no internal id for the deleted row. That makes it a detector rather than a key set: query it on every run to learn which record types lost rows since your last load, then refresh the landed tables those types map to. A keyed anti-join delete is not buildable from this surface. And the detector has a blind spot of its own: Oracle enumerates the record types (opens in new tab) that support deletion tracking at all — with a separate, smaller list for the analytics data source — so check your own tables against that list before treating a clean ledger as a clean extract; a record type outside it loses rows without ever writing a ledger row. (Note the naming: the Help Center documents it as deletedRecordInConnect, while the analytics record you query is deletedrecord— there is no separate record under the documentation’s name.)

The expensive part is what the ledger does not capture: deleted lines. Remove one line from a surviving order and nothing is written there at all. So the only safe policy for line-grain tables — transactionline, transactionaccountingline, inventoryassignment — is a periodic full refresh, or a re-pull of every document touched in the window keyed on the header id. Decide that policy at design time; discovering it at quarter close is the expensive version.

3 parameters not filled: <catalog>, <schema>, <watermark>

-- The modification stamp catches INSERTS AND UPDATES ONLY. A deleted row
-- simply stops existing - it never appears in a watermark window, so a
-- watermark-only pipeline keeps deleted documents forever.
SELECT
  t.id,
  t.lastmodifieddate
FROM <catalog>.<schema>.transaction t
WHERE
  1 = 1
  -- AND t.lastmodifieddate >= TIMESTAMP '<watermark>'  -- inserts and updates only — deletes never appear here; diff deletedrecord, which is itself blind to line-level deletes (see quirks #deletes)
;

-- deletedrecord is the ledger of what went away - record type, name, script
-- id, when, and by whom. Note what is NOT there: the deleted row's internal
-- id. The analytics record does not publish one, so this surface tells you
-- WHICH tables went stale, not which keys to delete.
SELECT
  dr.recordtypeid,
  dr.type,
  COUNT(*)            AS deleted_rows,
  MAX(dr.deleteddate) AS last_deletion
FROM <catalog>.<schema>.deletedrecord dr
WHERE dr.deleteddate >= TIMESTAMP '<watermark>'
GROUP BY dr.recordtypeid, dr.type;

-- Act on it with a refresh, not a keyed DELETE: any record type listed above
-- has lost rows your landed copy still holds. A keyed anti-join delete is not
-- buildable from this surface.

-- The gap that costs people a quarter-end: the ledger records DELETED
-- RECORDS, not deleted LINES. Remove one line from a surviving order and
-- nothing is written here at all. The only safe policy for line-grain tables
-- (transactionline, transactionaccountingline, inventoryassignment) is a
-- periodic full refresh - or a re-pull of every document touched in the
-- window, keyed on the header id.
--
-- Note the naming: the Help Center documents this surface as
-- deletedRecordInConnect; the analytics record you query is deletedrecord.
-- There is no separate record under the docs name.

Back to top ↑

On-hand value is a costing output

NetSuite values inventory by a costing method chosen per item — Average, FIFO, LIFO or Standard — and the method is a stored column, costingmethod on the item record. Two items in the same warehouse can be valued two different ways.

The consequence for analytics: on-hand value is the output of that engine, not a sum of what moved. Under Average the unit cost shifts with every receipt; under FIFO and LIFO the layers consumed depend on order rather than on any column you can add up; under Standard the difference lands in variance accounts elsewhere. Rebuilding inventory value by summing transaction amounts produces a number that looks plausible and reconciles to nothing.

Report quantity from the flows if you like — but report value from inventoryitemlocations, where NetSuite stores the valuation it computed alongside the balance, and say in the dashboard which one you used.

3 parameters not filled: <catalog>, <schema>, <location_id>

-- NetSuite values inventory by a COSTING METHOD chosen per item - Average,
-- FIFO, LIFO or Standard. The method is a stored column:
SELECT
  i.id,
  i.itemid,
  i.itemtype,
  i.costingmethod
FROM <catalog>.<schema>.item i;

-- On-hand VALUE is the OUTPUT of that method, not a sum of what moved. The
-- per-item-per-location balance record carries both the quantity and the
-- valuation NetSuite computed:
SELECT
  iil.item,
  iil.location,
  iil.quantityonhand,
  iil.onhandvaluemli,    -- valuation, produced by the costing engine
  iil.averagecostmli
FROM <catalog>.<schema>.inventoryitemlocations iil
WHERE
  iil.location = <location_id>  -- location scope — see quirks guide #oneworld
;

-- What NOT to do: rebuild value by summing transaction amounts.
--   SELECT SUM(tl.netamount) ...   -- does not reproduce inventory value
-- Under Average the unit cost moves with every receipt; under FIFO and LIFO
-- the layers consumed depend on order, not on any column you can sum; under
-- Standard the variance goes somewhere else entirely. Report QUANTITY from
-- the flows if you like - report VALUE from the balance record, and say in
-- the dashboard which one you used.

Back to top ↑

Maintained by Summit Analytics, a supply chain analytics practice. The tools and references are free — the consulting is selective.

Part of the Summit Analytics reference library.

Work with the practice

Not affiliated with or endorsed by Oracle or NetSuite. NetSuite is a registered trademark of Oracle and/or its affiliates.