Joins and reconciliation
Why does my SuiteQL RIGHT JOIN between transaction and transactionLine return 0 rows?
Short answer
RIGHT JOIN between transaction and transactionLine runs as an inner join and returns zero rows. FULL OUTER is downgraded to a LEFT join from the header. No error. LEFT JOIN from the header and Oracle (+) both work.
Because the RIGHT JOIN keyword was ignored and the statement ran as an inner join. Between transaction and transactionLine, RIGHT JOIN returns nothing and FULL OUTER JOIN returns the header side only, which is a LEFT join from the header, not an inner one. No error either way. LEFT JOIN from the header is honoured, and so is the Oracle (+) operator.
Minimal reproduction
Join on a condition that can never match. A working outer join then has to null-supply the preserved side, and an inner join has to return zero. One statement per call:
SELECT COUNT(*) AS n FROM transaction t LEFT JOIN transactionLine tl ON 1 = 0
SELECT COUNT(*) AS n FROM transaction t RIGHT JOIN transactionLine tl ON 1 = 0
SELECT COUNT(*) AS n FROM transaction t FULL OUTER JOIN transactionLine tl ON 1 = 0
Results:
| Call | Should return | Actually returned |
|---|---|---|
| LEFT | every transaction row |
every transaction row, correct |
| RIGHT | every transactionLine row |
0, an inner join |
| FULL OUTER | headers plus lines | the header count only, a LEFT join from the header |
RIGHT is the inner-join case: under ON 1 = 0 an inner join must return zero, and zero is what came back. FULL is not. An inner join would have returned zero here too, and FULL returned every header row instead, so it was downgraded to LEFT rather than to INNER. Writing the line table on the left changes nothing: transactionLine tl FULL OUTER JOIN transaction t ON 1 = 0 also came back as the header count.
The same shape against transactionAccountingLine does not run at all:
SELECT COUNT(*) AS n FROM transaction t RIGHT JOIN transactionAccountingLine tal ON 1 = 0
N/query.runSuiteQL error: Search error occurred: Invalid or unsupported search
That string is NetSuite's own text.
The fix
To preserve the header side and leave the line side optional, use a plain LEFT JOIN from the header. That is the measured working form: under a never-matching condition it still returned every transaction row.
SELECT COUNT(*) AS n FROM transaction t LEFT JOIN transactionLine tl ON tl.transaction = t.id
The Oracle (+) operator works too, with the operator on the optional side. This form also preserved every transaction row:
SELECT COUNT(*) AS n
FROM transaction t, transactionLine tl
WHERE tl.transaction(+) = t.id AND tl.id(+) = -999
The line side is the case this page cannot close. No call in this run demonstrated an outer join that preserves transactionLine against transaction. The candidate is (+) moved to the header side:
SELECT COUNT(*) AS n FROM transaction t, transactionLine tl WHERE tl.transaction = t.id(+)
It parses and runs, but the account had no orphan lines, so its result was identical to an inner join and its outer semantics were never shown. Run the check below on it before relying on it.
Why this happens
It is not a limitation of transactionLine. RIGHT JOIN from an unrelated table into transactionLine preserved every line row, and the control pair of two unrelated tables gave correct LEFT, RIGHT and FULL results, the FULL equalling the sum of both sides. It is the pair transaction plus transactionLine that triggers it.
Stated as hypothesis, because the run did not confirm it: when both sides belong to the same native NetSuite search hierarchy, a record and its line sublist, the engine substitutes its own built-in parent/child join and discards the requested outer semantics.
Worth knowing why this was nearly missed. On a real join condition, INNER, LEFT, LEFT OUTER, FULL OUTER, the comma join and (+) all returned an identical count, which proves nothing: where every header has a line and every line has a header, inner and outer coincide. You have to join ON 1 = 0 to see the difference.
Scope: one production account on NetSuite 2026.1. Every probe behind this page ran entirely through N/query.runSuiteQL; the finding was not tested on the REST endpoint.
How to tell if you are affected
Two calls. Replace the ON clause of the outer join you rely on with ON 1 = 0, then compare its count against a bare count of the table you expected to be preserved.
SELECT COUNT(*) AS n FROM transaction t RIGHT JOIN transactionLine tl ON 1 = 0
SELECT COUNT(*) AS n FROM transactionLine
If the two agree, the outer join was honoured. If the first returns 0 and the second does not, the keyword was ignored and you have been running an inner join. On this pair, the first returns 0.
For your own pair of tables the same check is a template. Substitute your table names:
SELECT COUNT(*) AS n FROM your_left_table a RIGHT JOIN your_right_table b ON 1 = 0
This matters most for the query nobody double-checks: the orphan hunt. Lines with no header, headers with no lines, accounting lines with no matching transaction line. Those come back empty here, and empty reads as a clean bill of health.
How this was established
7 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
- Why does my transactionAccountingLine join return far more rows than expected?
You joined on transaction alone. transactionAccountingLine must match transactionLine on both transaction and line id. The transaction-only join matches every accounting line against every line of the same document, silently multiplying rows and any sum built on them.
- Why can't I select subsidiary from transactionAccountingLine in SuiteQL?
There is no subsidiary column on transactionAccountingLine, and selecting it fails with a bare "Unexpected Error" that never names the column. Join transactionLine on both transaction and line id, then read subsidiary from there.
- What does the SuiteQL error 'Invalid or unsupported search' mean?
A generic refusal carrying no error code, no position and no identifier name. In this run it came from CAST(id AS VARCHAR(20)), a date column compared to a bare ISO string, GROUPING SETS, and one RIGHT JOIN.