What comes back

Why does SuiteQL return CustInvc instead of Invoice for transaction type?

Updated
suiteqltransactiontypebuiltin-dffilteringsilent-failure

Short answer

The type column holds internal codes: CustInvc, VendBill, ExpRept, CuTrSale, RevArrng and so on. A filter written against the display name Invoice matches no row. Use the code, or filter on BUILTIN.DF(type), which selected the same rows.

Because type stores the internal record type code, not the display name. CustInvc is an invoice, VendBill a bill, ExpRept an expense report. A filter naming 'Invoice' matches nothing. Use the code, or filter on BUILTIN.DF(type).

Minimal reproduction

SELECT UPPER(type) AS s, LOWER(type) AS t, LENGTH(type) AS l FROM transaction FETCH FIRST 3 ROWS ONLY

Output:

{ s: "EXPREPT",  t: "exprept",  l: 7 }
{ s: "CUSTINVC", t: "custinvc", l: 8 }
{ s: "CUSTINVC", t: "custinvc", l: 8 }

Seven and eight characters. Expense Report, one of the display names the same column resolves to through BUILTIN.DF, is fourteen. BUILTIN_RESULT.TYPE_STRING(type) does not translate either: it returns the same raw codes.

The codes

Grouping by type across the whole table returned these 25 codes:

CardChrg   CardRfnd   CashRfnd   CashSale   Check
Commissn   CustCred   CustDep    CustInvc   CustPymt
CustRfnd   CuTrSale   DepAppl    Estimate   ExpRept
Journal    Opprtnty   PurchOrd   RevArrng   SalesOrd
Transfer   VendBill   VendCred   VendPymt   Deposit

The per-group counts in that probe add up to the table's own COUNT(*), so the list accounts for every row in the account tested; narrower probes on the same column returned strict subsets of it. This is what one account happened to contain, so treat it as a starting list rather than the complete set of codes NetSuite defines. Note that no code in it contains a space.

The fix

Filter on the code:

SELECT COUNT(*) AS n FROM transaction WHERE type = 'VendBill'

Or filter on the display name through BUILTIN.DF:

SELECT COUNT(*) AS n FROM transaction WHERE BUILTIN.DF(type) = 'Bill'

Both returned the same count, selecting the identical row set. That equivalence was verified for one pair, VendBill and Bill, not for every type.

A simple CASE on the raw column matches those literals correctly. Run across the whole table with a GROUP BY, every VendBill row and every Journal row landed in its own bucket, with no leakage into the ELSE branch, and SUM(CASE WHEN type = 'VendBill' THEN 1 ELSE 0 END) agreed with the grouped count exactly. Two codes, not all 25, but hard-coding a code in a CASE is sound.

To see display names instead:

SELECT BUILTIN.DF(type) AS t, COUNT(*) AS n FROM transaction GROUP BY BUILTIN.DF(type) FETCH FIRST 20 ROWS ONLY

As run, that query hit its own 20-row cap, so what came back is a slice and not the full set of display names. It specified no order; alphabetised, the 20 were Bill, Bill Credit, Bill Payment, Cash Refund, Cash Sale, Check, Client Credit, Commission, Credit Card Charge, Credit Card Refund, Credit Memo, Customer Deposit, Customer Refund, Estimate, Expense Report, Invoice, Opportunity, Payment, Revenue Arrangement, Sales Order.

The two lists do not line up one to one by inspection, and the run did not establish the full mapping, so derive the pairing in your own account.

Why this happens

type is stored as the internal identifier. BUILTIN.DF resolves that identifier to its display value at query time, which is why the same predicate expressed either way selected the same rows. Nothing in the engine rewrites a display-name literal into a code for you, so a filter naming 'Invoice' compares a code column against a string that is not in the column.

Where the evidence stops: no probe in this run executed WHERE type = 'Invoice'. That it matches no row is derived from the enumerated distinct values of type, which account for every row in the table and contain no display names at all. The derivation is solid, but it is a derivation, and the run did not observe what such a filter actually returns.

How to tell if you are affected

SELECT type, COUNT(*) AS n FROM transaction GROUP BY type

Look at the first column. If the values read CustInvc and ExpRept, the column holds codes, and any filter or CASE in your codebase that compares type against Invoice or Expense Report is selecting nothing from it. If instead that column comes back full of display names, you are not affected and your literals are fine. The fast audit is to grep your queries for type = followed by a string containing a space, which no code in the list above has.

Scope

One production account on NetSuite 2026.1, transaction table. Every probe behind this page ran through N/query.runSuiteQL; not one was run against the REST endpoint, so the finding is untested there.

How this was established

8 probe runs against one live NetSuite 2026.1 account on the Administrator role, through N/query.runSuiteQL. Where this page draws a boundary around a finding, that boundary is the edge of what was actually run.

Related

From the blog