SuiteQL is Oracle NetSuite's SQL-based query language, introduced in 2020.1. It queries the SuiteAnalytics data source — the same data exposed in SuiteAnalytics Workbook — with role-based access restrictions enforced automatically. SuiteQL supports both SQL-92 and Oracle SQL syntax (but not mixed in a single query) and is available across all NetSuite editions that have SuiteCloud enabled. SuiteQL is read-only — it queries records but cannot insert, update, or delete them. [src1, src5]
| Property | Value |
|---|---|
| Vendor | Oracle |
| System | Oracle NetSuite 2026.1 |
| API Surface | SuiteQL (SQL-92 / Oracle SQL) |
| Current API Version | 2026.1 (Cloud Latest) |
| Editions Covered | All editions with SuiteCloud enabled |
| Deployment | Cloud |
| API Docs | SuiteQL Documentation |
| Status | GA (since 2020.1) |
SuiteQL is accessible through three primary interfaces, each with different record limits and pagination behavior. [src1, src2, src5, src6]
| API Surface | Protocol | Best For | Max Records/Request | Pagination | Governance | Real-time? |
|---|---|---|---|---|---|---|
| N/query (runSuiteQL) | SuiteScript 2.x | Quick in-script queries, <5K rows | 5,000 | None (single result set) | 10 units | Yes |
| N/query (runSuiteQLPaged) | SuiteScript 2.x | Large result sets, iterative processing | 1,000/page | Page-based (5-1,000 per page) | 10 units | Yes |
| SuiteTalk REST | HTTPS/JSON POST | External integrations, middleware | 3,000/page | limit/offset in URL params | N/A (REST rate limits) | Yes |
| SuiteAnalytics Connect | JDBC/ODBC | BI tools, bulk analytics, unlimited rows | Unlimited | Client-managed | N/A | No (near real-time) |
| Limit Type | Value | Applies To | Notes |
|---|---|---|---|
| Max records per runSuiteQL() | 5,000 | N/query module | Use runSuiteQLPaged() for larger sets [src6] |
| Max records per REST page | 3,000 | SuiteTalk REST API | Paginate with limit/offset [src8] |
| Max page size (paged) | 1,000 | runSuiteQLPaged() | Min 5, default 50 [src5] |
| Min page size (paged) | 5 | runSuiteQLPaged() | [src5] |
| CLOB sort limit | 250 characters | All interfaces | Sorting on CLOB fields only evaluates first 250 chars [src4] |
| Limit Type | Value | Window | Notes |
|---|---|---|---|
| Total REST query results | 100,000 rows | Per query | Hard ceiling — use SuiteAnalytics Connect for larger sets [src2] |
| SuiteScript governance | 10 units per query | Per execution | Same for runSuiteQL and runSuiteQLPaged [src6] |
| REST API concurrency | Account-level | Per account | Shared with all REST endpoints — NetSuite fair-use policy |
| SuiteAnalytics Connect | Unlimited rows | N/A | Requires feature enabled, uses NetSuite2.com data source [src2] |
NetSuite SuiteScript governance applies to SuiteQL when executed via the N/query module. Each script type has a total governance budget. [src6]
| Limit Type | Per-Transaction Value | Notes |
|---|---|---|
| runSuiteQL() cost | 10 units | Per call, regardless of result size [src6] |
| runSuiteQLPaged() cost | 10 units | Per call, pages iterate within same budget [src5] |
| Scheduled Script budget | 10,000 units | Can run ~1,000 SuiteQL queries per execution |
| Map/Reduce budget | 10,000 units per phase | Use for very large data processing jobs |
| User Event Script budget | 1,000 units | Limits SuiteQL calls in before/afterSubmit |
| Client Script budget | 1,000 units | Limits SuiteQL calls in browser context |
| Flow | Use When | Token Lifetime | Refresh? | Notes |
|---|---|---|---|---|
| OAuth 2.0 Authorization Code | REST API with user context, new integrations | Access: configurable, Refresh: until revoked | Yes | Now the recommended flow for REST Web Services [src2] |
| Token-Based Auth (TBA) | REST API server-to-server, legacy | Until revoked | No expiry | OAuth 1.0 signature — still fully supported [src2] |
| SuiteScript context | In-script queries (N/query) | Script execution duration | N/A | Runs as the user/role executing the script [src5] |
| SuiteAnalytics Connect | JDBC/ODBC BI tools | Session-based | Reconnect | Separate credentials [src1] |
Prefer: transient header — omitting it returns a 400 error with no useful message. [src2][ ] is not supported in SuiteQL syntax. [src4]START — User needs to query NetSuite data
├── Where is the query executed?
│ ├── Inside NetSuite (SuiteScript)
│ │ ├── Result set < 5,000 rows?
│ │ │ ├── YES → query.runSuiteQL() — simplest, 10 governance units
│ │ │ └── NO → query.runSuiteQLPaged() — 1,000 rows/page, iterate
│ │ └── Need complex SQL (JOINs, subqueries, UNION)?
│ │ ├── YES → SuiteQL (this card)
│ │ └── NO, simple field lookups → search.lookupFields (5 units, faster)
│ ├── External system (middleware, app)
│ │ ├── Result set < 100,000 rows?
│ │ │ ├── YES → SuiteTalk REST /query/v1/suiteql — POST, paginate
│ │ │ └── NO → SuiteAnalytics Connect (JDBC/ODBC) — no row ceiling
│ │ └── Need real-time results?
│ │ ├── YES → REST API (sub-second for simple queries)
│ │ └── NO → SuiteAnalytics Connect (better for bulk)
│ └── BI/Reporting tool
│ └── SuiteAnalytics Connect → JDBC/ODBC driver, unlimited rows
├── What SQL features are needed?
│ ├── JOINs across multiple record types → SuiteQL
│ ├── UNION queries → SuiteQL only (not in saved searches)
│ ├── Subqueries → SuiteQL only
│ ├── GROUP BY + HAVING → SuiteQL
│ └── Simple filters + sort → Saved search may suffice
└── Need to write data?
├── YES → Use SuiteTalk REST/SOAP or N/record module
└── NO → SuiteQL is the right choice
| SQL Feature | Supported? | Syntax Example | Notes |
|---|---|---|---|
| SELECT | Yes | SELECT id, companyname FROM customer | Field names use internal IDs [src3] |
| WHERE | Yes | WHERE isperson = 'T' | Supports =, <>, >, <, LIKE, IN, BETWEEN [src3] |
| JOIN (INNER) | Yes | INNER JOIN transactionLine ON ... | Full ANSI JOIN syntax [src3] |
| JOIN (LEFT/RIGHT) | Yes | LEFT JOIN customer ON ... | Outer joins supported [src7] |
| GROUP BY | Yes | GROUP BY email | Standard SQL aggregation [src3] |
| HAVING | Yes | HAVING COUNT(*) > 2 | Filter on aggregates [src3] |
| ORDER BY | Yes | ORDER BY createdate DESC | CLOB fields: first 250 chars only [src4] |
| UNION | Yes | SELECT ... UNION SELECT ... | Not available in saved searches [src3] |
| Subqueries | Yes | WHERE id IN (SELECT ...) | In SELECT, FROM, and WHERE [src3] |
| TOP N | Yes | SELECT TOP 10 ... | Alternative to LIMIT [src3] |
| DISTINCT | Yes | SELECT DISTINCT email | Deduplicates results [src3] |
| CASE | Yes | CASE WHEN ... THEN ... END | Conditional logic [src7] |
| Aggregates | Yes | COUNT(*), SUM(amount) | COUNT, SUM, AVG, MIN, MAX [src3] |
| COALESCE | Yes | COALESCE(email, 'none') | Null handling [src3] |
| EXISTS | Yes | WHERE EXISTS(SELECT 1 FROM ...) | Existence checks [src3] |
| Parameter binding | Yes | WHERE field = ? with params array | Prevents SQL injection [src5] |
| INSERT/UPDATE/DELETE | No | N/A | SuiteQL is read-only [src1] |
| Mixed SQL-92 + Oracle | No | N/A | Pick one syntax per query [src4] |
Use query.runSuiteQL() for result sets under 5,000 rows. This is the simplest approach for in-script data access. [src5, src6]
/**
* @NApiVersion 2.1
* @NScriptType ScheduledScript
*/
define(['N/query', 'N/log'], (query, log) => {
const execute = (context) => {
const results = query.runSuiteQL({
query: `
SELECT id, companyname, email, datecreated
FROM customer
WHERE isinactive = 'F'
ORDER BY datecreated DESC
`
});
const customers = results.asMappedResults();
log.debug('Customer count', customers.length);
// Parameterized query — prevents SQL injection
const filtered = query.runSuiteQL({
query: `
SELECT id, companyname FROM customer
WHERE subsidiary = ? AND datecreated > TO_DATE(?, 'YYYY-MM-DD')
`,
params: [2, '2025-01-01']
});
log.debug('Filtered count', filtered.asMappedResults().length);
};
return { execute };
});
Verify: Check script execution log → expected: Customer count: [N]
For result sets exceeding 5,000 rows, use paged execution with page sizes between 5 and 1,000. [src5]
/**
* @NApiVersion 2.1
* @NScriptType MapReduceScript
*/
define(['N/query', 'N/log'], (query, log) => {
const getInputData = (context) => {
const pagedData = query.runSuiteQLPaged({
query: `
SELECT t.id, t.tranid, t.trandate, tl.item, tl.amount
FROM transaction t
INNER JOIN transactionLine tl ON t.id = tl.transaction
WHERE t.type = 'SalesOrd'
AND t.trandate >= TO_DATE('2025-01-01', 'YYYY-MM-DD')
ORDER BY t.trandate DESC
`,
pageSize: 1000
});
const allResults = [];
pagedData.pageRanges.forEach((pageRange) => {
const page = pagedData.fetch({ index: pageRange.index });
allResults.push(...page.data.asMappedResults());
});
return allResults;
};
const map = (context) => { /* process each row */ };
const summarize = (summary) => { /* log results */ };
return { getInputData, map, summarize };
});
Verify: Map/Reduce deployment log → expected: Total rows: [N]
POST to /services/rest/query/v1/suiteql with limit and offset URL parameters. The Prefer: transient header is required. [src2]
curl -X POST \
'https://ACCOUNT_ID.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql?limit=1000&offset=0' \
-H 'Content-Type: application/json' \
-H 'Prefer: transient' \
-H 'Authorization: OAuth realm="ACCOUNT_ID",oauth_consumer_key="...",oauth_token="...",...' \
-d '{"q": "SELECT id, companyname, email FROM customer WHERE isinactive = '\''F'\'' ORDER BY id"}'
Verify: Response contains totalResults, hasMore, and items array
Loop through pages using offset until hasMore is false or you hit the 100K ceiling. [src2, src8]
import requests
from requests_oauthlib import OAuth1
auth = OAuth1(client_key='KEY', client_secret='SECRET',
resource_owner_key='TOKEN', resource_owner_secret='TOKEN_SECRET',
realm='ACCOUNT_ID', signature_method='HMAC-SHA256')
base_url = 'https://ACCOUNT_ID.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql'
sql = "SELECT id, companyname FROM customer WHERE isinactive = 'F' ORDER BY id"
all_records, offset, limit = [], 0, 1000
while True:
resp = requests.post(f'{base_url}?limit={limit}&offset={offset}',
json={'q': sql}, auth=auth,
headers={'Prefer': 'transient', 'Content-Type': 'application/json'})
resp.raise_for_status()
data = resp.json()
all_records.extend(data['items'])
if not data.get('hasMore', False) or offset >= 100000:
break
offset += limit
Verify: len(all_records) matches totalResults (if under 100K)
# Input: OAuth 1.0 credentials, SuiteQL query string
# Output: All matching records as list of dicts (up to 100K)
import requests # requests==2.31.0
from requests_oauthlib import OAuth1 # requests-oauthlib==1.3.1
def run_suiteql(account_id, auth, query, max_rows=100000):
"""Execute SuiteQL via REST with automatic pagination."""
url = f'https://{account_id}.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql'
headers = {'Prefer': 'transient', 'Content-Type': 'application/json'}
results, offset, limit = [], 0, 1000
while offset < max_rows:
resp = requests.post(f'{url}?limit={limit}&offset={offset}',
json={'q': query}, auth=auth, headers=headers)
resp.raise_for_status()
data = resp.json()
results.extend(data['items'])
if not data.get('hasMore', False):
break
offset += limit
return results
// Input: SuiteQL query string
// Output: Array of result objects
define(['N/query'], (query) => {
function runSuiteQLAll(sql, params) {
const pagedData = query.runSuiteQLPaged({
query: sql, params: params || [], pageSize: 1000
});
const results = [];
pagedData.pageRanges.forEach((range) => {
results.push(...pagedData.fetch({ index: range.index }).data.asMappedResults());
});
return results;
}
return { runSuiteQLAll };
});
# Input: NetSuite account ID, TBA credentials configured
# Output: JSON array of first 10 customers
curl -X POST \
'https://ACCOUNT_ID.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql?limit=10' \
-H 'Content-Type: application/json' \
-H 'Prefer: transient' \
-H 'Authorization: OAuth realm="ACCOUNT_ID",...' \
-d '{"q": "SELECT TOP 10 id, companyname, email FROM customer"}'
| UI Label | SuiteQL Record/Field | Type | Notes |
|---|---|---|---|
| Customer | customer | Record type | Internal name is lowercase [src7] |
| Company Name | companyname | String | No underscores in standard fields |
| Transaction | transaction | Record type | Includes all transaction types |
| Transaction Line | transactionLine | Record type | JOIN via transactionLine.transaction = transaction.id |
| Item | item | Record type | All item types (inventory, service, etc.) |
| Sales Order type | type = 'SalesOrd' | Filter | Type codes differ from UI names |
| Main Line flag | mainline = 'T' or 'F' | Boolean-like | 'T' = header, 'F' = line items [src7] |
| Custom field | custbody_fieldid / custcol_fieldid | Custom | Body-level vs column-level |
'T' and 'F' strings, not true/false — standard SQL boolean literals do not work. [src7]TO_DATE('2025-01-01', 'YYYY-MM-DD'), not ANSI date literals. [src3]asMappedResults() are always returned in lowercase — SELECT id AS myID returns myid. [src4]| Code | Meaning | Cause | Resolution |
|---|---|---|---|
| INVALID_SEARCH | Query syntax error | Malformed SQL, unsupported function, or mixed syntax | Validate in SuiteAnalytics Workbook; use Oracle SQL syntax [src4] |
| SSS_USAGE_LIMIT_EXCEEDED | Governance units exhausted | Too many queries in one script execution | Move to Map/Reduce script type [src6] |
| MISSING_REQD_ARGUMENT | Required parameter missing | Omitted query property | Ensure options has query string [src6] |
| SSS_INVALID_TYPE_ARG | Invalid parameter type | Non-string/number in params | Only string, number, boolean in params [src6] |
| INSUFFICIENT_PERMISSION | Role lacks access | Missing SuiteAnalytics permissions | Enable SuiteAnalytics Workbook on role [src1] |
| HTTP 400 | Bad request (REST) | Missing Prefer: transient header | Add required header [src2] |
| HTTP 401 | Authentication failed | Invalid or expired TBA tokens | Regenerate OAuth tokens [src2] |
Check totalResults in first response; switch to SuiteAnalytics Connect if >100K. [src2]Use Oracle SQL syntax; add ROWNUM or TOP N limits during development. [src4]asMappedResults() lowercases all aliases. Fix: Always reference result fields in lowercase. [src4]= 'CuTrSale' on TranType returns unexpected results. Fix: Use LIKE: WHERE TranType LIKE 'CuTrSale%'. [src4]Sort on a non-CLOB field or truncate in subquery. [src4]// BAD — wastes governance units and memory
const all = query.runSuiteQL({ query: 'SELECT * FROM customer' });
const active = all.asMappedResults().filter(c => c.isinactive === 'F');
// GOOD — database does the filtering, returns fewer rows
const active = query.runSuiteQL({
query: "SELECT id, companyname, email FROM customer WHERE isinactive = 'F'"
});
// BAD — silently truncates at 5,000 rows with no error
const results = query.runSuiteQL({
query: 'SELECT id, tranid, amount FROM transaction ORDER BY trandate DESC'
});
// results.asMappedResults() has at most 5,000 rows — data loss!
// GOOD — iterates all pages, no truncation
const paged = query.runSuiteQLPaged({
query: 'SELECT id, tranid, amount FROM transaction ORDER BY trandate DESC',
pageSize: 1000
});
const results = [];
paged.pageRanges.forEach(range => {
results.push(...paged.fetch({ index: range.index }).data.asMappedResults());
});
-- BAD — SQL-92 JOIN syntax risks performance issues and timeouts
SELECT t.id, c.companyname
FROM transaction t, customer c
WHERE t.entity = c.id AND t.trandate > DATE '2025-01-01'
-- GOOD — Oracle syntax avoids performance issues
SELECT t.id, c.companyname
FROM transaction t
INNER JOIN customer c ON t.entity = c.id
WHERE t.trandate > TO_DATE('2025-01-01', 'YYYY-MM-DD')
Always use runSuiteQLPaged() for any query that might exceed 5K rows. [src6]Write one query with IN clause or JOIN instead of N queries in a loop. [src6]Always check totalResults; use SuiteAnalytics Connect for >100K datasets. [src2]Standardize on Oracle SQL syntax. [src4]Use Records Browser or SELECT * FROM {recordType} WHERE ROWNUM <= 1. [src7]Verify role has SuiteAnalytics Workbook permission enabled. [src1]# Test SuiteQL connectivity via REST
curl -X POST \
'https://ACCOUNT_ID.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql?limit=1' \
-H 'Content-Type: application/json' -H 'Prefer: transient' \
-H 'Authorization: OAuth ...' \
-d '{"q": "SELECT TOP 1 id, companyname FROM customer"}'
# Verify field names for a record type
curl -X POST \
'https://ACCOUNT_ID.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql?limit=1' \
-H 'Content-Type: application/json' -H 'Prefer: transient' \
-H 'Authorization: OAuth ...' \
-d '{"q": "SELECT * FROM customer WHERE ROWNUM <= 1"}'
# Count total records (check for 100K ceiling risk)
curl -X POST \
'https://ACCOUNT_ID.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql' \
-H 'Content-Type: application/json' -H 'Prefer: transient' \
-H 'Authorization: OAuth ...' \
-d '{"q": "SELECT COUNT(*) as total FROM transaction"}'
| Feature / Version | Release | Status | Notes |
|---|---|---|---|
| SuiteQL introduced | 2020.1 | GA | First availability in N/query module [src6] |
| REST /query/v1/suiteql | 2020.1 | GA | POST endpoint for external queries [src2] |
| SuiteAnalytics Connect | Pre-2020 | GA | JDBC/ODBC, unlimited rows [src1] |
| runSuiteQLPaged() | 2020.1 | GA | Paged execution, 5-1000 page size [src5] |
| customScriptId parameter | 2021.1+ | GA | Performance tracking [src6] |
| metaDataProvider parameter | 2023.1+ | GA | SUITE_QL or STATIC permission checking [src6] |
| NetSuite.com data source EOL | 2025.1 | Ended | Support ended; migrate to NetSuite2.com data source |
| NetSuite.com data source removal | 2026.1 | Removed | NetSuite.com fully removed; NetSuite2.com only |
| OAuth 2.0 promoted as default | 2025.x | GA | OAuth 2.0 now recommended over TBA for REST |
| SOAP Web Services last endpoint | 2025.2 | Last | No new SOAP endpoints from 2026.1 |
Oracle NetSuite follows a cloud-only release model with biannual major releases (YYYY.1 and YYYY.2). SuiteQL is the strategic query interface — Oracle has not announced any deprecation timeline. Saved searches remain supported but new features (UNION, complex subqueries) are SuiteQL-only. The NetSuite.com SuiteAnalytics data source was removed in 2026.1 — all SuiteQL queries now run against the NetSuite2.com data source, which has renamed some tables and fields (e.g., standalone Invoice table deprecated in favor of unified Transactions table). [src1, src7]
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Complex JOINs across multiple record types | Simple single-record lookups by ID | search.lookupFields (5 governance units) |
| UNION queries combining different record types | Need to write/update records | N/record module or SuiteTalk REST API |
| GROUP BY + HAVING aggregations | End-user dashboards and portlets | Saved searches with summary types |
| Subqueries in WHERE or FROM | Result set exceeds 100K rows (REST) | SuiteAnalytics Connect (JDBC/ODBC) |
| External middleware querying NetSuite | Real-time event notifications | User Event scripts or SuiteFlow |