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 columnOneWorld: 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;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;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.Three ways one document points at another
Because every document shares one spine, the chain between them is a set of links rather than a foreign key per document type — and there are three mechanisms, not one. createdfrom on the line names the upstream transaction, which is cheap but header-grain. The two link tables, nexttransactionlinelink and previoustransactionlinelink, carry the same links at line grain with a linktype on each row — one read downstream, the other upstream. Oracle documents the underlying behavior as transaction links (opens in new tab).
Direction discipline first: pick the table that matches your traversal and use only that one. The two are the same links read from opposite ends, so joining both to be thorough multiplies the chain by itself.
Then fan-out. One sales-order line can produce several fulfillment lines and several invoice lines, so a chained join returns the order line once per downstream line — and any COUNT(*) or naive SUM over that result is inflated by the branching, not by the data. De-duplicate before you count: distinct document ids, or collapse the links to the grain you actually report at.
The chain worth walking end to end is order-to-cash: quote, sales order, item fulfillment, invoice, customer payment. All five are type values on transaction — the record map on this site names each one and lands you on the same table every time.
3 parameters not filled: <catalog>, <schema>, <subsidiary_id>
-- Three different mechanisms point one document at another, and they are not
-- interchangeable:
--
-- 1. transactionline.createdfrom - the upstream TRANSACTION the line was
-- created from. Cheap, but header-grain: it names the document, not the
-- line.
-- 2. nexttransactionlinelink - LINE to LINE, travelling downstream.
-- 3. previoustransactionlinelink - the same links, read upstream.
--
-- Pick the table that matches your direction of travel. Joining both "to be
-- safe" multiplies the chain by itself.
-- Downstream from a sales-order line: what did this line become?
SELECT
tl.transaction AS order_doc,
tl.id AS order_line,
tl.item,
tl.quantity,
ntll.nextdoc AS downstream_doc,
ntll.nexttype AS downstream_type,
ntll.nextline AS downstream_line,
ntll.linktype,
ntll.foreignamount
FROM <catalog>.<schema>.transactionline tl
JOIN <catalog>.<schema>.nexttransactionlinelink ntll
ON ntll.previousdoc = tl.transaction
AND ntll.previousline = tl.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
;
-- Upstream from an invoice line, the mirror read - same shape, other table:
SELECT
tl.transaction AS invoice_doc,
ptll.previousdoc AS upstream_doc,
ptll.previoustype AS upstream_type,
ptll.previousline AS upstream_line
FROM <catalog>.<schema>.transactionline tl
JOIN <catalog>.<schema>.previoustransactionlinelink ptll
ON ptll.nextdoc = tl.transaction
AND ptll.nextline = tl.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
;
-- FAN-OUT is the thing that bites. One order line can produce many fulfillment
-- lines and many invoice lines, so a chained join returns the order line once
-- per downstream line. Count DISTINCT, or collapse the links first:
SELECT
COUNT(DISTINCT tl.transaction) AS orders, -- NOT COUNT(*)
COUNT(DISTINCT ntll.nextdoc) AS linked_documents
FROM <catalog>.<schema>.transactionline tl
JOIN <catalog>.<schema>.nexttransactionlinelink ntll
ON ntll.previousdoc = tl.transaction
AND ntll.previousline = tl.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
;
-- The order-to-cash chain these links traverse - quote, sales order, item
-- fulfillment, invoice, customer payment - is five type values on ONE spine.
-- The record map on this site names which analytics table each of those
-- documents lives in; the answer is always transaction.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 transaction → transactionline → 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.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.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.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;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.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;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.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.