Skip to content
SuiteQL

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.

ERPray teamUpdated 7 min read
Short answer

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 <= 1 is 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 name column, 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 beginning custrecord. Both are literal — SuiteQL does not resolve display names, so Project Milestone is not a table and Status is 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.

PrefixWhat it isWhere you query it
customrecord_*A custom record typeIt 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 entityA column on customer, vendor, employee
custbody_*A transaction body fieldA column on transaction
custcol_*A transaction line (column) fieldA column on transactionline
custitem_*A custom field on itemsA column on item
custevent_*A custom field on CRM recordsA column on the relevant activity/case table
customlist_*A custom list of valuesReferenced by id from a field; use BUILTIN.DF() for the label
The prefix tells you which table to look on, before you go hunting in the Records Catalog.

How to query NetSuite custom records, step by step

  1. 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_milestone and customrecord_project_milestone are different tables and one of them does not exist.

  2. 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 every custrecord_* 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.

  3. 03

    Pin down the standard columns you can rely on

    Custom record tables carry a small set of built-in columns alongside your fields — id and isinactive on every one, plus name if the record type has the name field enabled, an externalid, 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.

  4. 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.

  5. 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.

  6. 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 <= 1
Step 2 in practice: discovery on an unfamiliar custom record
SELECT
    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_date
Step 4 and 5: explicit columns, labels resolved

That 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.

Free calculator
SuiteQL formatter

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_date
Custom record joined to customer and to the invoice it references

The 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')
From transactions out to a custom record referenced on the body

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 typeWhat the column returnsHow to handle it
Free-form text, text areaThe stringQuote your literals; watch apostrophes in the data
Integer, decimal, currencyA numberNulls are common on older rows — NVL() before arithmetic
Check box'T' or 'F', sometimes nullNVL(col, 'F') = 'T', never = 1 or = true
List/RecordThe internal idBUILTIN.DF() for the label, or join to the source table
DateA dateCompare with TO_DATE(..., 'YYYY-MM-DD') and a half-open range
Date/TimeA timestamp in the account's timezoneMind the end-of-day boundary; shift the watermark for a UTC consumer
Multiple SelectNot a simple scalar you can join onVerify what your account returns before designing around it — see the note below
PercentCheck whether it is 0.15 or 15Test one known row rather than assuming
Checkbox and List/Record fields account for most of the wrong answers.

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 DESC
Parent with rolled-up children

The 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.

Your ERP already knows. Start asking.

ERPray computes answers like these live from your own ERP account and shows the exact query behind every number. Early access is open for NetSuite teams — free plan at launch.