SuiteQL checker
Every rule the su-ns-harness SuiteQL checker applies before a query reaches NetSuite, and how to override a false positive with [nolint].
On this page
Before a SuiteQL call goes out, the PreToolUse hook checks the query. A deny blocks the call, and Claude gets the reason plus a fix, often a rewritten query. A warn lets the call through and puts the warning in Claude's context.
You can run the same checks yourself without sending anything:
nsx sql lint "SELECT entity, SUM(foreigntotal) FROM transaction WHERE ROWNUM <= 100 GROUP BY entity"That example exits with status 1. It has the ROWNUM bug described below, and the output includes the rewritten query.
Each table below names the rule id. That's the name nsx sql lint prints and the one you pass to [nolint:<rule>].
Row caps and paging
ROWNUM caps rows before GROUP BY and ORDER BY run. You get wrong totals and a wrong top-N with no error. Moving ROWNUM to an outer query doesn't help, because NetSuite still applies the outer ROWNUM before the inner GROUP BY. The fix is ORDER BY … FETCH FIRST n ROWS ONLY at the end of the query.
| Rule | Id | Action |
|---|---|---|
ROWNUM at the same level as GROUP BY or aggregates | rownum-with-aggregate | Deny, even with [nolint]. Rewritten with FETCH FIRST |
ROWNUM in a subquery that feeds an aggregate | rownum-before-aggregate | Deny |
ROWNUM at the same level as ORDER BY (wrong top-N) | rownum-with-order-by | Deny. Rewritten with FETCH FIRST |
ROWNUM over an aggregated or sorted subquery, such as SELECT * FROM (<GROUP BY … ORDER BY …>) WHERE ROWNUM <= N. NetSuite applies the outer ROWNUM first | rownum-over-subquery | Deny, even with [nolint]. Rewritten with FETCH FIRST |
A ROWNUM condition that can never match a row: ROWNUM > n (n ≥ 1), ROWNUM = n, >= n or BETWEEN n AND … with n > 1, or ROWNUM <> 1 | rownum-greater-than | Deny, even with [nolint] |
LIMIT n, which SuiteQL doesn't have | limit-clause | Deny. Rewritten as FETCH FIRST n ROWS ONLY |
LIMIT n OFFSET m | limit-clause | Deny, even with [nolint], because the OFFSET rule below fires too. Page with pageSize + pageIndex and a unique ORDER BY |
OFFSET n ROWS with n > 0, for example OFFSET 100 ROWS FETCH NEXT 100 ROWS ONLY. NetSuite ignores the OFFSET and returns the first rows again | offset-ignored | Deny, even with [nolint]. When the query ends in OFFSET n ROWS FETCH NEXT m ROWS ONLY, m is at least 5 and n is a multiple of m, the suggested fix drops both clauses and calls with pageSize: m, pageIndex: n/m. Otherwise page with pageSize + pageIndex, or WHERE t.id > <last id> |
pageIndex above 0 on a detail query without ORDER BY. Pages can overlap or skip rows | (none) | Warn |
Query shape
| Rule | Id | Action |
|---|---|---|
Anything but a SELECT query (DELETE, UPDATE, DROP…). The connector only runs read-only queries anyway, so this rule exists to fail fast with a message that names the statement | not-select | Deny, even with [nolint] |
More than one statement (SELECT …; SELECT …). A single trailing ; is fine | multi-statement | Deny, even with [nolint] |
HTML-escaped operators outside string literals (<=, >, &, <) | html-entity | Deny, even with [nolint], naming the entity |
An incomplete query: SELECT with no column list or no FROM <table>. Usually an unquoted query cut short on the command line | incomplete-query | Deny |
x.col where x isn't a table or alias in FROM or JOIN, in this query or an enclosing one | undefined-alias | Deny |
SELECT * on a table. Probes with FETCH FIRST 1 or ROWNUM <= 1 are fine, and so is SELECT * over a subquery | select-star | Deny |
WITH and CTEs. Oracle's docs say "You can't use WITH clauses" | cte-unsupported | Deny. Rewrite as a FROM subquery |
[bracketed] identifiers | bracket-identifier | Deny |
More than 1000 items in one IN (…) | in-list-too-long | Deny |
Syntax SuiteQL doesn't support
| Rule | Id | Action |
|---|---|---|
Functions SuiteQL doesn't support: LEFT, RIGHT, SUBSTRING, CHARINDEX, CEILING, LISTAGG, DATEDIFF, … | unsupported-function | Deny, with the supported alternative (SUBSTR, INSTR, CEIL, …) |
DATE '…' and TIMESTAMP '…' literals | date-literal | Deny. Use TO_DATE(…) |
+ next to a text literal, or next to a column the cached metadata types as string (a.acctnumber + a.acctname) | string-plus-concat | Deny. Concatenate with the double-pipe operator instead |
(+) outer joins mixed with ANSI JOIN … ON in one query block | mixed-join-syntax | Deny |
(+) on the left of = (Oracle right outer join) | right-outer-plus | Deny. Swap the operands |
A double-quoted value compared to a column. SuiteQL reads "CustInvc" as a column name | double-quoted-string | Warn. Use single quotes |
ORDER BY on the alias of an aggregate or expression in a GROUP BY query, such as ORDER BY n DESC for COUNT(*) AS n. NetSuite can reject it with "Invalid or unsupported search" | order-by-alias-group-by | Warn. Order by the full expression (COUNT(*) DESC) |
A date column compared to a plain string (trandate >= '2026-01-01') | string-date-compare | Warn. Use TO_DATE(…) |
The double-pipe operator is ||, as in a.acctnumber || ' ' || a.acctname.
Values and tables
| Rule | Id | Action |
|---|---|---|
type = 'invoice' or recordtype = 'CustInvc': codes and record ids swapped | type-literal, recordtype-literal | Deny, with the right value |
A type or recordtype value the plugin doesn't know. Its lists aren't complete | type-literal-unknown, recordtype-unknown | Warn. The value may still be right. Check it if the query returns nothing |
approvalstatus compared to a value, for a type the account doesn't use approvals for. The filter would return zero rows | approvalstatus-no-workflow | Deny |
status = 'A' without BUILTIN.CF. Raw status holds only the letter | status-without-cf | Warn. Use BUILTIN.CF(t.status) = 'CustInvc:A' |
| A table missing from the cached record-type list, meaning the connector role can't see it | (none) | Deny before the call. Plain [nolint] overrides it, in case the list is out of date |
| A column missing from the connector's field metadata for a cached table | unknown-column, unknown-custom-field | Warn, with the closest name (amout → amount) |
The unknown-column rule only warns because the connector's field metadata is incomplete. transactionline.amount, for one, works but isn't listed. A bad column fails with a generic "unexpected SuiteScript error" that doesn't name the field, so the warning is what tells Claude which column to suspect.
Totals that add the wrong things
| Rule | Id | Action |
|---|---|---|
Summing transactionline amounts without a mainline or line-type filter | transactionline-sum-unfiltered | Warn |
SUM(foreigntotal) or SUM(foreignamount) without currency in the GROUP BY or a filter to one currency. This adds currencies together | sum-mixed-currency | Warn. Suggests a base amount per subsidiary, the consolidated report or BUILTIN.CONSOLIDATE, or BUILTIN.CURRENCY_CONVERT |
SUM of transactionaccountingline amount, debit, credit or netamount without grouping or filtering by subsidiary, on an account whose subsidiaries use different currencies | sum-across-subsidiaries | Warn. Group or filter by tl.subsidiary, or use the consolidated report |
| Budgets and transaction tables joined and aggregated in one query (budget-vs-actual fan-out) | budget-fanout | Warn |
A date or posting filter in LEFT JOIN … ON while aggregating a left-table column. The left-side sums inflate | left-join-aggregate | Warn |
A date or posting filter on the left table inside LEFT JOIN … ON. It removes no rows | left-join-on-filter | Warn. Move it to WHERE |
The currency SUM rules skip counts like SUM(CASE WHEN … THEN 1 ELSE 0 END) or SUM(DECODE(…, 1, 0)).
Overriding a false positive
Add a marker to the query's description. That's the connector's description field, not the SQL:
[nolint:<rule>]overrides one rule, for example[nolint:select-star]. Separate several rules with commas.[nolint]overrides every rule that can be overridden.
Plain [nolint] is also how Claude queries a table the cached record-type list doesn't have yet. That check has no rule id, so [nolint:<rule>] can't target it.
Overridden errors are listed in Claude's context, so Claude checks the result against them.
What can't be overridden
Nothing overrides the rules marked "even with [nolint]" above, because their results would always be wrong:
ROWNUMwithGROUP BYor aggregatesROWNUMover a sorted or aggregated subqueryROWNUMconditions that never match a rowOFFSET- anything but a
SELECT - a second statement
- HTML-escaped operators
[nolint] also doesn't skip the first-use metadata step. The first query on a table whose fields aren't cached still triggers one ns_getSuiteQLMetadata call first, so the columns get checked instead of guessed.
nsx doctor counts the guard's denials per rule over the last 7 days with activity. A rule that shows up often may be a false positive worth reporting.
Install su-ns-harness
Run both inside Claude Code, then /reload-plugins. Version 0.5.1, free and open source.
/plugin marketplace add suiteutils/suiteutils-netsuite-claude-harness/plugin install su-ns-harness@suiteutils
Page last updated 2026-09-28. NetSuite is a trademark of Oracle. Claude is a trademark of Anthropic. Suite Utils is not affiliated with either.