How to query NetSuite custom records with SuiteQL
Query NetSuite custom records with SuiteQL: find the customrecord_ table name, discover custrecord_ fields, and join to customers and transactions.
In SuiteQL a custom record type is a table named after its script ID — customrecord_project_milestone — and its custom fields are columns named custrecord_*. Query it like any table: SELECT id, name, custrecord_status FROM customrecord_project_milestone. Fields that store a list or record reference hold the internal id, so wrap them in BUILTIN.DF() or join to the source table.
Key takeaways
- The table name is the custom record type's script ID exactly as configured —
customrecord_plus whatever the person who built it typed. SELECT * FROM customrecord_x WHERE ROWNUM <= 1is the fastest field discovery there is: the response keys are the column names.- If 'Include Name Field' was switched off when the record type was built, there is no
namecolumn, and every query assuming one fails. - List and record fields store internal ids.
BUILTIN.DF()gives you the label; a join gives you the label plus everything else on the source record. - Custom record types have their own permission setting, so a query that returns 4,000 rows as administrator can return zero for the role that needs it.
Querying NetSuite custom records in SuiteQL is easier than the standard tables, once you know the naming rules — there is no mainline filter, no transaction type code, and no header-and-line split. A custom record type is one table, its fields are columns, and the whole thing behaves like a table you designed yourself. The friction is entirely in discovery: finding the exact table name, finding out which columns exist, and turning internal ids into something a human recognises.
- Custom record table naming
- A custom record type is exposed as a table named by its script ID, always beginning
customrecord. Its custom fields are columns beginningcustrecord. Both are literal — SuiteQL does not resolve display names, soProject Milestoneis not a table andStatusis not a column.
The prefixes, and where each one lives
NetSuite encodes the field's home in its prefix. Getting this wrong is the usual reason a column "does not exist" — the field is real, you are just looking on the wrong table.
| Prefix | What it is | Where you query it |
|---|---|---|
customrecord_* | A custom record type | It is the table name in FROM |
custrecord_* | A custom field on a custom record (and other record custom fields) | A column on the customrecord_* table |
custentity_* | A custom field on an entity | A column on customer, vendor, employee |
custbody_* | A transaction body field | A column on transaction |
custcol_* | A transaction line (column) field | A column on transactionline |
custitem_* | A custom field on items | A column on item |
custevent_* | A custom field on CRM records | A column on the relevant activity/case table |
customlist_* | A custom list of values | Referenced by id from a field; use BUILTIN.DF() for the label |
How to query NetSuite custom records, step by step
- 01
Get the exact script ID
In NetSuite go to Customization > Lists, Records, & Fields > Record Types and open the record type. The ID field is your table name, verbatim, including any suffix the account added when the ID collided. Copy it rather than retyping it —
customrecord_proj_milestoneandcustomrecord_project_milestoneare different tables and one of them does not exist. - 02
Dump one row to discover the columns
Run
SELECT * FROM customrecord_project_milestone WHERE ROWNUM <= 1. The keys in the response are the exact column names, including everycustrecord_*field and the standard audit columns. This is faster than reading the field list in the UI and it reflects what the query layer actually exposes, which is the thing you care about. - 03
Pin down the standard columns you can rely on
Custom record tables carry a small set of built-in columns alongside your fields —
idandisinactiveon every one, plusnameif the record type has the name field enabled, anexternalid, an owner, and created/last-modified audit stamps. Names for the audit columns vary a little, so take them from the step-2 dump rather than assuming. - 04
Write the query with an explicit column list
Replace
SELECT *with the columns you need. This keeps the payload small and, more importantly, means your query fails loudly if someone renames a field rather than silently returning a different shape to whatever consumes it. - 05
Resolve list and record references
Any field of type List/Record — a status, a customer, another custom record — stores the internal id. Wrap it in
BUILTIN.DF()for the display label, or join to the source table when you need more than the label. Checkbox fields return the strings'T'and'F', not booleans. - 06
Rerun it as the role that will use it
Custom record types have their own access configuration, and it is common for a record type to be locked down in a way the administrator never notices. Verify the row count as the production role or integration user before you ship anything that depends on it.
SELECT * FROM customrecord_project_milestone WHERE ROWNUM <= 1SELECT
m.id,
m.name AS milestone,
BUILTIN.DF(m.custrecord_pm_status) AS status,
BUILTIN.DF(m.custrecord_pm_customer) AS customer,
m.custrecord_pm_due_date AS due_date,
m.custrecord_pm_value AS milestone_value,
m.custrecord_pm_invoiced AS invoiced_flag
FROM customrecord_project_milestone m
WHERE NVL(m.isinactive, 'F') = 'F'
AND m.custrecord_pm_due_date < TO_DATE('2026-08-01', 'YYYY-MM-DD')
AND NVL(m.custrecord_pm_invoiced, 'F') = 'F'
ORDER BY m.custrecord_pm_due_dateThat returns unbilled milestones due before August. Two details worth copying: NVL(..., 'F') on the checkbox fields, because an unchecked box on a record created before the field existed can be null rather than 'F'; and TO_DATE with an explicit format mask, so the query does not depend on the session's date preference.
Custom record queries get long fast, because the column names are long. Format them so the joins are readable next month.
Joining custom records to standard records
This is where custom records earn their keep. A custrecord_* field of type List/Record stores the internal id of the target record, so the join is a plain equality against that table's id.
SELECT
c.entityid AS customer_no,
c.companyname,
m.name AS milestone,
m.custrecord_pm_value AS milestone_value,
t.tranid AS invoice_no,
t.trandate AS invoice_date,
t.foreignamountunpaid AS open_amount
FROM customrecord_project_milestone m
JOIN customer c ON c.id = m.custrecord_pm_customer
LEFT JOIN transaction t ON t.id = m.custrecord_pm_invoice
WHERE NVL(m.isinactive, 'F') = 'F'
AND m.custrecord_pm_due_date >= TO_DATE('2026-01-01', 'YYYY-MM-DD')
ORDER BY c.companyname, m.custrecord_pm_due_dateThe LEFT JOIN on the invoice is deliberate. An inner join would quietly drop every milestone that has not been invoiced yet — which, for a report about unbilled work, is the entire population you care about. Getting inner and left the wrong way round is the most expensive mistake in this genre of query; SuiteQL join examples works through the standard paths in more detail.
The relationship also runs the other way. If a transaction body field holds a custom record id, join from the transaction:
SELECT
t.tranid,
t.trandate,
BUILTIN.DF(t.entity) AS customer,
t.foreigntotal,
BUILTIN.DF(cr.custrecord_ct_region) AS contract_region,
cr.name AS contract
FROM transaction t
JOIN customrecord_service_contract cr ON cr.id = t.custbody_service_contract
WHERE t.type = 'CustInvc'
AND t.trandate >= TO_DATE('2026-04-01', 'YYYY-MM-DD')
AND t.trandate < TO_DATE('2026-07-01', 'YYYY-MM-DD')Filter on the id, not on BUILTIN.DF(). WHERE t.custbody_service_contract = 8812 is a key comparison; WHERE BUILTIN.DF(t.custbody_service_contract) = 'Northwind MSA' makes the database resolve a label for every candidate row. The difference is not subtle on a large transaction table — see SuiteQL performance tips.
Field types, and what each one gives you back
| Field type | What the column returns | How to handle it |
|---|---|---|
| Free-form text, text area | The string | Quote your literals; watch apostrophes in the data |
| Integer, decimal, currency | A number | Nulls are common on older rows — NVL() before arithmetic |
| Check box | 'T' or 'F', sometimes null | NVL(col, 'F') = 'T', never = 1 or = true |
| List/Record | The internal id | BUILTIN.DF() for the label, or join to the source table |
| Date | A date | Compare with TO_DATE(..., 'YYYY-MM-DD') and a half-open range |
| Date/Time | A timestamp in the account's timezone | Mind the end-of-day boundary; shift the watermark for a UTC consumer |
| Multiple Select | Not a simple scalar you can join on | Verify what your account returns before designing around it — see the note below |
| Percent | Check whether it is 0.15 or 15 | Test one known row rather than assuming |
Parent-child custom records
There is no line-level sublist on a custom record the way there is on a transaction. A "sublist" is a second custom record type with a custrecord_* field pointing back at the parent, so the query is an ordinary one-to-many join and aggregation works normally.
SELECT
p.id,
p.name AS contract,
COUNT(c.id) AS milestone_count,
SUM(NVL(c.custrecord_pm_value, 0)) AS contract_value,
MIN(c.custrecord_pm_due_date) AS next_due
FROM customrecord_service_contract p
LEFT JOIN customrecord_project_milestone c
ON c.custrecord_pm_contract = p.id
AND NVL(c.isinactive, 'F') = 'F'
WHERE NVL(p.isinactive, 'F') = 'F'
GROUP BY p.id, p.name
HAVING COUNT(c.id) > 0
ORDER BY 4 DESCThe child-side isinactive filter belongs in the ON clause, not the WHERE clause. Put it in WHERE and you convert the left join back into an inner join, losing every parent with no active children — exactly the rows an exception report is looking for.
Permissions, and the empty result set
Custom record types are configured with their own access model: a record type can require its own permission, defer to a permission list, or be open to anyone with access to the account. Whoever built the record chose one, possibly years ago, possibly by accident. The result is that a SuiteQL query against a custom record can return a full set for an administrator and nothing at all for the role that needs it, with no error to explain the difference.
Check two things before you hand a query over: the record type's access configuration, and the actual row count as the role or integration user that will run it in production. This is the same silent-filtering behaviour that catches people on standard tables, and it is the first thing to check when "the query returns nothing" — the full set of quiet failures is in common SuiteQL errors explained.
Why custom fields vanish from reports but not from SuiteQL
A recurring complaint is that a custom field shows on the record but refuses to appear in a report or a search join. Field type, whether the field is stored, and which record types it is applied to all affect where it can be reported on — that whole subject is reporting on NetSuite custom fields. SuiteQL sidesteps a good part of it, because if the column exists on the table you can select it, join it and aggregate it without asking the reporting layer's permission. That is the strongest single argument for learning SuiteQL in a heavily customised account.
Extracting the result is usually the last step. For a one-off review, dump the rows and open them in a spreadsheet with the JSON to CSV converter; going the other way, if you are staging a list of ids to filter on, the CSV to JSON converter will shape a pasted column into something you can feed a script.
The part that stays awkward is discovery. In an account with 40 custom record types and 300 custom fields, the honest bottleneck is that nobody remembers what custrecord_pm_st2 means. That is the problem attacks first: it builds a dictionary of your account's own tables, custom records and custom fields, uses it to generate the query, and shows you the SuiteQL alongside the answer — so you can check it named the right custrecord_ field before you trust the number.
Frequently asked questions
What is the table name for a custom record in SuiteQL?
The record type's script ID, which always starts with customrecord. Find it under Customization > Lists, Records, & Fields > Record Types, in the ID field, and copy it exactly — accounts often append a suffix when an ID collides. SuiteQL does not resolve display names, so the ID is the only thing that works.
How do you list the fields on a NetSuite custom record in SuiteQL?
Run SELECT * FROM customrecord_your_record WHERE ROWNUM <= 1. The keys in the response are the exact column names, including every custrecord_* field and the built-in audit columns. The Records Catalog browser in your account gives the same information with data types and join paths.
How do you join a custom record to a customer in SuiteQL?
A List/Record custom field stores the target's internal id, so join on it directly: JOIN customer c ON c.id = m.custrecord_pm_customer. Use a LEFT JOIN when the field can be empty, otherwise you silently drop every custom record row where the link has not been set.
Why does BUILTIN.DF return nothing on my custom field?
BUILTIN.DF() resolves a list or record reference to its display value, so it only works on fields that hold an internal id. On a free-text, numeric or date field there is nothing to resolve. If the field is a List/Record and the result is still empty, the stored id likely points at a deleted or inactive value.
Do custom record checkbox fields return true or false in SuiteQL?
Neither. They return the strings 'T' and 'F', and on rows created before the field existed they can be null. Always write NVL(col, 'F') = 'T' rather than comparing to a boolean or to 1, or you will lose the older rows without any error to tell you.
Why does my custom record query return no rows for one role?
Custom record types have their own access configuration, and SuiteQL runs under the calling role's permissions. A record type set to require its own permission returns nothing to a role that lacks it — no error, just an empty result. Check the record type's access setting and rerun the query as the production role.
Calculators for this
Format SuiteQL and SQL in your browser: keyword casing, clause line breaks, indented joins and subqueries, plus a one-line minify mode.
Convert CSV to a JSON array of objects in your browser. Quoted fields, any delimiter, optional header row, type coercion, and ragged-row warnings.
Convert a JSON array to CSV in your browser. Column union across every object, dot-notation flattening, correct quote escaping, delimiter choice.
Keep reading
Why NetSuite custom fields disappear from reports: store value, applies-to, field type and role access, plus how to query custbody and custcol fields.
Worked SuiteQL join examples: transaction to transactionline to customer, item and custom record joins, plus when left joins change the answer.
A SuiteQL tutorial for beginners: where to run queries, how the tables map to records, and eight worked queries you can paste in today.
Common SuiteQL errors in one table: unknown identifier, ambiguous column, GROUP BY, invalid number, date format and REST header failures, plus the fix.