Skip to content
SuiteQL

SuiteQL join examples that cover most real queries

Worked SuiteQL join examples: transaction to transactionline to customer, item and custom record joins, plus when left joins change the answer.

ERPray teamUpdated 6 min read
Short answer

SuiteQL joins work like standard SQL: transactionline.transaction = transaction.id links lines to headers, transaction.entity = customer.id links a sale to its customer, and transactionline.item = item.id names the product. Use inner joins where rows must exist on both sides, and left joins where the related record is optional — an inner join to item silently drops shipping and tax lines.

Key takeaways

  • Four join keys cover most NetSuite querying: transactionline.transaction = transaction.id, transaction.entity = customer.id, transactionline.item = item.id, and transaction.postingperiod = accountingperiod.id.
  • An inner join to item drops every tax, shipping and discount line. That is sometimes what you want and is never what you expected.
  • Conditions on the optional side of a left join belong in the ON clause. Put them in WHERE and the left join becomes an inner join with no warning.
  • Joining to lines multiplies the header, so switch COUNT(*) to COUNT(DISTINCT transaction.id) and never sum a header column after a line join.
  • Self-joins on transaction via createdfrom trace the order-to-invoice chain — a relationship no saved search can express.

SuiteQL joins are ordinary SQL joins. What makes them feel unfamiliar is NetSuite's schema: transactions split across a header and a line table, entities layered into a shared parent plus type-specific children, and a mainline flag that will double every total you compute until you know about it. These SuiteQL join examples are the ones worth memorising, in the order you will need them.

The join keys worth memorising

FromToOn
transactiontransactionlinetransactionline.transaction = transaction.id
transactionlinetransactionaccountinglinetal.transaction = tl.transaction AND tal.transactionline = tl.id
transactioncustomercustomer.id = transaction.entity
transactionentityentity.id = transaction.entity (works for any entity type)
transactionlineitemitem.id = transactionline.item
transactionaccountingperiodaccountingperiod.id = transaction.postingperiod
transactionaccountinglineaccountaccount.id = tal.account
transactionlinesubsidiarysubsidiary.id = transactionline.subsidiary
transactiontransaction (self)child.createdfrom = parent.id
customrecord_*any standard recordstd.id = cr.custrecord_your_reference_field
Column availability varies with enabled features and customisation. Confirm with SELECT * on one row in your own account before relying on a query.

1. Header to line: the join you will write a thousand times

SELECT
    t.tranid                    AS invoice_no,
    t.trandate,
    BUILTIN.DF(t.entity)        AS customer,
    tl.linesequencenumber       AS line_no,
    BUILTIN.DF(tl.item)         AS item,
    tl.quantity,
    tl.netamount
FROM transaction t
JOIN transactionline tl ON tl.transaction = t.id
WHERE t.type = 'CustInvc'
  AND t.trandate >= TRUNC(SYSDATE) - 30
  AND tl.mainline = 'F'
ORDER BY t.trandate DESC, tl.linesequencenumber
Item lines on invoices from the last 30 days, with the header reference on each row.

tl.mainline = 'F' excludes the header summary line that every transaction carries. Without it you get an extra row per transaction holding the full total, and every SUM roughly doubles. If a number looks about twice as big as it should be, this is the reason nine times out of ten.

2. Header to line to item: where left versus inner actually matters

Not every transaction line has an item. Shipping, tax, discount and expense lines have a null item, so an inner join to item silently removes them. Whether that is correct depends on the question. For item profitability, dropping them is right. For reconciling to the invoice total, dropping them makes you short by exactly the freight and tax.

SELECT
    t.tranid                                    AS invoice_no,
    tl.linesequencenumber                       AS line_no,
    NVL(i.itemid, '-- non-item line --')        AS item_or_charge,
    NVL(BUILTIN.DF(i.itemtype), 'other')        AS item_type,
    tl.netamount
FROM transaction t
JOIN transactionline tl ON tl.transaction = t.id
LEFT JOIN item i        ON i.id = tl.item
WHERE t.type = 'CustInvc'
  AND t.tranid = 'INV20418'
  AND tl.mainline = 'F'
ORDER BY tl.linesequencenumber
Every line on an invoice, with the item name where one exists and a label where it does not.

3. Entity joins: customer, vendor, or the shared parent

transaction.entity holds an internal id from the shared entity table, which is the parent of customers, vendors, employees, partners and contacts. You have a choice of what to join to, and the choice does work for you.

  • Join to `customer` when you want customer-only fields (terms, creditlimit, custentity_*). Because only customer ids exist in that table, the join itself acts as a filter.
  • Join to `entity` when the query spans types — an AP and AR report in one list, or an audit of everything a given entity touched.
  • Use `BUILTIN.DF(t.entity)` when all you need is the name. It saves a join and reads better.
SELECT
    c.entityid,
    c.companyname,
    BUILTIN.DF(c.terms)                 AS payment_terms,
    c.custentity_credit_tier            AS credit_tier,
    COUNT(DISTINCT t.id)                AS open_invoices,
    SUM(t.foreigntotal)                 AS open_value
FROM customer c
JOIN transaction t ON t.entity = c.id
WHERE t.type   = 'CustInvc'
  AND t.status = 'CustInvc:A'
  AND c.isinactive = 'F'
GROUP BY c.entityid, c.companyname, BUILTIN.DF(c.terms), c.custentity_credit_tier
ORDER BY open_value DESC
FETCH FIRST 50 ROWS ONLY
Open invoices with the customer's payment terms and credit tier — three tables, one statement.

No line join here, so SUM(t.foreigntotal) is safe — the header total is counted once per invoice. The moment you add a transactionline join, that sum becomes nonsense and you have to move to summing line amounts instead. Deciding which side of that line you are on is most of the skill.

Free calculator
SuiteQL formatter

Multi-join queries are unreadable as a single line. Format before review, minify before pasting into a SuiteScript string.

4. Anti-join: finding what is missing

The most useful queries often return rows that do not exist elsewhere. Customers who stopped ordering. Items never sold. Purchase orders received but never billed. All three are left joins with a null test, and none of them can be built as a single saved search.

SELECT
    po.tranid                       AS po_number,
    po.trandate,
    BUILTIN.DF(po.entity)           AS vendor,
    po.foreigntotal,
    rcpt.tranid                     AS receipt_no,
    rcpt.trandate                   AS received_on
FROM transaction po
JOIN transaction rcpt
       ON rcpt.createdfrom = po.id
      AND rcpt.type = 'ItemRcpt'
LEFT JOIN transaction bill
       ON bill.createdfrom = po.id
      AND bill.type = 'VendBill'
WHERE po.type = 'PurchOrd'
  AND bill.id IS NULL
  AND rcpt.trandate < TRUNC(SYSDATE) - 14
ORDER BY rcpt.trandate
Purchase orders with a receipt but no vendor bill — the classic three-way match gap.

Three references to transaction in one query, two of them self-joins through createdfrom. This is the shape a saved search cannot produce at all, and it is a genuinely useful report: goods you have received and not been billed for, older than a fortnight, which is an accrual waiting to surprise you at close.

5. Joining to the GL: accounts, subsidiaries and periods

SELECT
    s.name                          AS subsidiary,
    a.acctnumber,
    BUILTIN.DF(tal.account)         AS account_name,
    ap.periodname,
    SUM(tal.debit)                  AS total_debit,
    SUM(tal.credit)                 AS total_credit,
    SUM(tal.amount)                 AS net_amount
FROM transactionaccountingline tal
JOIN transaction      t  ON t.id  = tal.transaction
JOIN transactionline  tl ON tl.transaction = tal.transaction
                        AND tl.id          = tal.transactionline
JOIN account          a  ON a.id  = tal.account
JOIN accountingperiod ap ON ap.id = t.postingperiod
LEFT JOIN subsidiary  s  ON s.id  = tl.subsidiary
WHERE t.posting   = 'T'
  AND ap.isadjust = 'F'
  AND ap.periodname = 'Jun 2026'
GROUP BY s.name, a.acctnumber, BUILTIN.DF(tal.account), ap.periodname
ORDER BY s.name, a.acctnumber
Posted amounts by account, subsidiary and period — the join that ties to the trial balance.

The compound join between transactionline and transactionaccountingline needs both keys — transaction and line — because line ids are only unique within a transaction. t.posting = 'T' restricts to documents that actually hit the GL, which drops sales orders, quotes and other non-posting records. The subsidiary join is a left join so single-instance accounts without OneWorld still return rows.

6. Custom records, joined both ways

SELECT
    audit.id,
    audit.name                              AS audit_ref,
    audit.custrecord_audit_date             AS audit_date,
    c.companyname                           AS customer,
    BUILTIN.DF(audit.custrecord_audit_owner) AS owner,
    COUNT(f.id)                             AS finding_count
FROM customrecord_equipment_audit audit
JOIN customer c
       ON c.id = audit.custrecord_audit_customer
LEFT JOIN customrecord_audit_finding f
       ON f.custrecord_finding_audit = audit.id
WHERE audit.custrecord_audit_date >= ADD_MONTHS(TRUNC(SYSDATE, 'MM'), -3)
GROUP BY audit.id, audit.name, audit.custrecord_audit_date,
         c.companyname, BUILTIN.DF(audit.custrecord_audit_owner)
HAVING COUNT(f.id) > 0
ORDER BY finding_count DESC
A custom record joined out to a customer and in from its own child list.

Swap in your own record and field ids; the pattern is what carries over. Custom record tables are named after the record id, and every custom field becomes a column with its own custrecord_ id. Get both from Customization > Lists, Records & Fields, not from the label on the form — labels get renamed and ids do not. More on discovery and field types in how to query NetSuite custom records.

7. A correlated subquery, when a join would multiply

SELECT
    c.entityid,
    c.companyname,
    SUM(tal.amount)                 AS ar_balance,
    (SELECT MAX(t2.trandate)
       FROM transaction t2
      WHERE t2.entity = c.id
        AND t2.type   = 'CustInvc')  AS last_invoiced
FROM customer c
JOIN transaction              t   ON t.entity   = c.id
JOIN transactionaccountingline tal ON tal.transaction = t.id
JOIN account                  a   ON a.id       = tal.account
WHERE t.posting = 'T'
  AND a.accttype = 'AcctRec'
GROUP BY c.id, c.entityid, c.companyname
HAVING SUM(tal.amount) <> 0
ORDER BY ar_balance DESC
Each customer's AR balance derived from GL postings, with their most recent invoice date.

Summing every posting to an AR-type account per customer nets invoices against payments and credit memos, so the result is a live receivables balance rather than a list of invoices. The subquery for last invoice date is correlated rather than joined, because joining a second transaction set would multiply the AR rows. Once you have those balances, bucket them with the AR aging calculator to get weighted average days overdue and a reserve estimate.

Join hygiene: five rules that prevent wrong answers

RuleWhySymptom when broken
Filter mainline = 'F' on any line joinEvery transaction carries a header summary lineTotals roughly double
COUNT(DISTINCT t.id), never COUNT(*), after a line joinOne header becomes N line rowsTransaction counts inflate by average line count
Never SUM a header column after a line joinThe header amount repeats per lineRevenue equals total × lines per order
Optional-side conditions go in ON, not WHERENull columns fail WHERE predicatesLeft join silently behaves as inner join
Pair every status filter with a type filterStatus codes are namespaced by transaction typeZero rows, or the wrong document type
Every one of these produces a query that runs cleanly and returns a plausible wrong number.

Performance notes for multi-join queries

  • Filter the driving table first. A date or type predicate on transaction shrinks the set before the line join happens. Filtering only on a joined table's column makes the engine build the wide result first.
  • Join `transactionaccountingline` last and only when you need GL amounts. It is the widest of the transaction tables and the most expensive to fan out.
  • Prefer `BUILTIN.DF()` over a join when you want one label. It saves a table from the plan.
  • Read the query before you tune it. A five-table join written as one line hides its own bugs; the SuiteQL formatter puts one clause per line so you can see which table drives the plan.
  • Bound everything. A relative date range costs nothing to add and prevents a full history scan. When you need exact fiscal boundaries rather than rolling days, work them out with the fiscal period calculator.

If any of the syntax here was unfamiliar, the ground floor is in SuiteQL tutorial for beginners, and ready-made versions of the AR, open-order and revenue queries are collected in the SuiteQL transaction queries cookbook.

Why the join is the thing worth checking

Every wrong number in this post came from a join, not from arithmetic. A missing mainline filter, a WHERE that should have been an ON, a header sum after a line join. None of them throw an error. All of them return something that looks right.

That is why prints the SuiteQL underneath every answer it gives. You are not being asked to trust that the join was right — you can read it. Scan for mainline, check which side the date filter sits on, confirm the posting flag, and either accept the number or tell it to try again. Read-only by default, NetSuite-first, running as a dedicated instance you control.

Frequently asked questions

How do I join transaction and transactionline in SuiteQL?

Join on transactionline.transaction = transaction.id, then add transactionline.mainline = 'F' to exclude the header summary line every transaction carries. Without that filter you get an extra row per transaction holding the full total, which roughly doubles any SUM. Add taxline = 'F' as well if tax posts to separate lines in your account.

When should I use a left join instead of an inner join in SuiteQL?

Whenever the related record is optional. Transaction lines for shipping, tax and discounts have no item, so an inner join to item drops them. Customers may have no transactions, and purchase orders may have no bill. Use a left join plus IS NULL to find what is missing, which is often the most useful report.

Why does my left join in SuiteQL behave like an inner join?

You put a condition on the optional table in the WHERE clause. Unmatched rows return nulls for that table's columns, and null fails a WHERE predicate, so those rows disappear. Move the condition into the ON clause — ON i.id = tl.item AND i.isinactive = 'F' — and the outer rows survive.

How do I join a NetSuite custom record to a standard record in SuiteQL?

Query the custom record table by its record id, such as customrecord_equipment_audit, and join on the custom field that stores the reference: JOIN customer c ON c.id = audit.custrecord_audit_customer. Get exact ids from Customization > Lists, Records & Fields rather than the form label, since labels get renamed and internal ids do not.

Can SuiteQL do a self-join on the transaction table?

Yes, and it is one of the biggest advantages over saved searches. Alias the table twice and join child.createdfrom = parent.id to trace order to fulfilment to invoice, or purchase order to receipt to bill. That relationship has no expression in a single saved search, which is why three-way-match reports usually end up in a spreadsheet.

How do I get customer names in a SuiteQL query without joining?

Wrap the field in BUILTIN.DF(), so BUILTIN.DF(transaction.entity) returns the customer name instead of internal id 4821. It works on any list or record field, including custom ones, and saves a table from the query plan. Join to the customer table instead when you also need terms, credit limit or custom entity fields.

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.