SuiteQL date functions: the ones you actually need
SuiteQL date functions explained: TO_DATE, TRUNC, ADD_MONTHS and MONTHS_BETWEEN, with period filters and the timezone traps that skew totals.
SuiteQL uses Oracle date functions: TO_DATE turns a string into a date, TRUNC strips the time or rounds down to a month, ADD_MONTHS shifts by whole months, and MONTHS_BETWEEN returns the fractional gap. Filter trandate with a half-open range rather than equality, because createddate carries a time component and equality drops rows.
Key takeaways
- TO_DATE('2026-06-30', 'YYYY-MM-DD') is the safe way to write a date literal. Always pass the format mask so the query does not depend on a session setting.
- Use half-open ranges — >= start AND < next start — instead of BETWEEN. BETWEEN on a column with a time component drops everything after midnight on the last day.
- trandate has no time. createddate and lastmodified do. Mixing them up is the single most common cause of a SuiteQL date filter returning too few rows.
- Financial questions filter on postingperiod joined to accountingperiod, not on trandate. A 31 January invoice entered in February belongs to February's numbers.
- TRUNC(trandate, 'MM') gives calendar month buckets, which only match your fiscal periods if your fiscal year is calendar-aligned and you run 12 equal months.
Dates are where correct-looking SuiteQL goes wrong. The query runs, returns rows, and is short by a day or a month or one late-entered invoice. SuiteQL date functions are Oracle's, so the functions themselves are well behaved — the problems come from NetSuite's schema, where some columns carry a time and some do not, and where the date on a transaction is not the period it belongs to.
The function list, and what each is for
| Function | Example | Returns |
|---|---|---|
TO_DATE | TO_DATE('2026-06-30', 'YYYY-MM-DD') | A date value from a string. Always pass the format mask. |
TRUNC (date) | TRUNC(createddate) | Midnight of the same day — strips the time component. |
TRUNC with a unit | TRUNC(trandate, 'MM') | First day of the month. 'Q' for quarter, 'YYYY' for year, 'IW' for ISO week start. |
ADD_MONTHS | ADD_MONTHS(TRUNC(SYSDATE,'MM'), -6) | Shifts by whole months and handles month-end correctly. |
LAST_DAY | LAST_DAY(trandate) | Last calendar day of that month. |
MONTHS_BETWEEN | MONTHS_BETWEEN(SYSDATE, datecreated) | Fractional months between two dates. |
SYSDATE | TRUNC(SYSDATE) - 30 | Current date and time. Subtracting an integer subtracts days. |
TO_CHAR | TO_CHAR(trandate, 'YYYY-MM') | A formatted string, useful as a grouping key that sorts correctly. |
EXTRACT | EXTRACT(YEAR FROM trandate) | A numeric year, month or day part. |
NVL | NVL(duedate, trandate) | A fallback when the date can be null — not a date function, but you will use it on dates constantly. |
Rule one: half-open ranges, never BETWEEN
BETWEEN is inclusive on both ends, which is fine on a pure date column and wrong on anything with a time. createddate BETWEEN TO_DATE('2026-06-01','YYYY-MM-DD') AND TO_DATE('2026-06-30','YYYY-MM-DD') matches nothing created after midnight on 30 June — so it silently loses a day of records, every time, in every report built on it.
-- Wrong: loses everything on 30 June after 00:00
SELECT COUNT(*) FROM transaction
WHERE createddate BETWEEN TO_DATE('2026-06-01', 'YYYY-MM-DD')
AND TO_DATE('2026-06-30', 'YYYY-MM-DD');
-- Right: >= start of period, < start of next period
SELECT COUNT(*) FROM transaction
WHERE createddate >= TO_DATE('2026-06-01', 'YYYY-MM-DD')
AND createddate < TO_DATE('2026-07-01', 'YYYY-MM-DD');Write every date filter this way and you never have to remember which columns carry a time. The upper bound is the first instant you do not want.
Rule two: transaction date is not the accounting period
This is the difference between a query that agrees with finance and one that does not. trandate is the document date. postingperiod is the accounting period the GL effect landed in. An invoice dated 31 January and entered on 3 February, after January closed, has a January trandate and a February posting period. Filter on trandate and your monthly revenue will never tie to the trial balance.
SELECT
ap.periodname,
ap.startdate,
ap.enddate,
SUM(-tal.amount) AS revenue
FROM transactionaccountingline tal
JOIN transaction t ON t.id = tal.transaction
JOIN accountingperiod ap ON ap.id = t.postingperiod
JOIN account a ON a.id = tal.account
WHERE t.posting = 'T'
AND ap.isadjust = 'F'
AND ap.isquarter = 'F'
AND ap.isyear = 'F'
AND a.accttype IN ('Income', 'OthIncome')
AND ap.startdate >= ADD_MONTHS(TRUNC(SYSDATE, 'MM'), -6)
GROUP BY ap.periodname, ap.startdate, ap.enddate
ORDER BY ap.startdateThree period filters earn their place. isadjust = 'F' keeps period 13 out of a monthly series. isquarter and isyear exclude the summary rows in the period hierarchy, which would otherwise double-count everything inside them. Revenue is negated because income accounts post as credits. Whether your own account needs those exact flags depends on how your fiscal calendar was built, so check the period list once and reuse the filter forever.
Work out which fiscal year, quarter and period a date falls in — including 4-4-5 calendars — before you hard-code period boundaries into a query.
Rule three: bucket by month with TRUNC, not by string
SELECT
TRUNC(t.trandate, 'MM') AS month_start,
TO_CHAR(t.trandate, 'YYYY-MM') AS month_label,
COUNT(DISTINCT t.id) AS invoices,
SUM(tl.netamount) AS invoiced_value
FROM transaction t
JOIN transactionline tl ON tl.transaction = t.id
WHERE t.type = 'CustInvc'
AND tl.mainline = 'F'
AND t.trandate >= ADD_MONTHS(TRUNC(SYSDATE, 'MM'), -12)
AND t.trandate < TRUNC(SYSDATE, 'MM')
GROUP BY TRUNC(t.trandate, 'MM'), TO_CHAR(t.trandate, 'YYYY-MM')
ORDER BY month_startGroup by the truncated date and label with TO_CHAR. Grouping only on the string works but sorts alphabetically, which is fine for 'YYYY-MM' and breaks the moment someone asks for 'Mon YYYY'. The upper bound excludes the current partial month, so the chart does not end in a misleading dip. Calendar months are not fiscal periods — if your fiscal year starts in April or you run 4-4-5, use the accountingperiod join instead.
Rule four: age things against a truncated SYSDATE
SELECT
BUILTIN.DF(t.entity) AS customer,
t.tranid AS invoice_no,
t.trandate,
t.duedate,
TRUNC(SYSDATE) - t.duedate AS days_overdue,
CASE
WHEN t.duedate IS NULL THEN 'no due date'
WHEN TRUNC(SYSDATE) - t.duedate <= 0 THEN 'current'
WHEN TRUNC(SYSDATE) - t.duedate <= 30 THEN '1-30'
WHEN TRUNC(SYSDATE) - t.duedate <= 60 THEN '31-60'
WHEN TRUNC(SYSDATE) - t.duedate <= 90 THEN '61-90'
ELSE '90+'
END AS aging_bucket,
t.foreigntotal
FROM transaction t
WHERE t.type = 'CustInvc'
AND t.status = 'CustInvc:A'
ORDER BY days_overdue DESCTRUNC(SYSDATE) rather than bare SYSDATE, so the day count is a whole number instead of a fraction that puts an invoice due today into the 1-30 bucket. duedate can be null on transactions with no terms, so the CASE handles it explicitly rather than letting those rows fall into '90+' through null arithmetic. Feed the output into the AR aging calculator for bucket mix, weighted average days overdue and a reserve estimate.
Rule five: MONTHS_BETWEEN returns a fraction
SELECT
c.entityid,
c.companyname,
c.datecreated,
FLOOR(MONTHS_BETWEEN(TRUNC(SYSDATE), c.datecreated)) AS tenure_months,
(SELECT FLOOR(MONTHS_BETWEEN(TRUNC(SYSDATE), MAX(t.trandate)))
FROM transaction t
WHERE t.entity = c.id
AND t.type = 'SalesOrd') AS months_since_order
FROM customer c
WHERE c.isinactive = 'F'
AND c.datecreated < ADD_MONTHS(TRUNC(SYSDATE), -12)
ORDER BY months_since_order DESC NULLS FIRSTMONTHS_BETWEEN returns a decimal — 14.7419 rather than 14 — so wrap it in FLOOR when you want whole months. ADD_MONTHS handles month-end properly, giving 28 February for a year before 29 February, which naive day arithmetic gets wrong. NULLS FIRST surfaces customers who have never ordered, who are usually the interesting ones.
Rule six: dates stored as text need explicit conversion
SELECT
r.id,
r.custrecord_contract_ref AS contract_ref,
r.custrecord_renewal_date_text AS raw_value,
TO_DATE(r.custrecord_renewal_date_text, 'DD/MM/YYYY') AS renewal_date,
TO_DATE(r.custrecord_renewal_date_text, 'DD/MM/YYYY')
- TRUNC(SYSDATE) AS days_to_renewal
FROM customrecord_service_contract r
WHERE r.custrecord_renewal_date_text IS NOT NULL
AND TO_DATE(r.custrecord_renewal_date_text, 'DD/MM/YYYY')
BETWEEN TRUNC(SYSDATE) AND TRUNC(SYSDATE) + 90
ORDER BY renewal_dateThis works and it is fragile. One row with 31/13/2026 or an empty string that is not null and the whole query fails on conversion, because the error happens per row during evaluation. If you inherit a text date field, the durable fix is a real date field and a one-off data cleanse, not a cleverer query. BETWEEN is safe here because both sides are already truncated dates.
Timezones, and why two people get two answers
NetSuite renders date/time values according to a timezone setting, and the value you see in the UI is not necessarily the value the query engine compares. The practical consequences are narrow but real.
- A record created late in the evening can appear on either side of a day boundary depending on whose timezone is applied. Same-day filters on
createddateare the ones affected. SYSDATEreflects the database server's clock, not your desk. For anything where a one-day shift matters, pass the boundary date in as an explicit parameter rather than deriving it fromSYSDATE.trandateis date-only, so it is immune. This is a good reason to prefer it for business reporting and reservecreateddatefor audit questions.- Reports that must reconcile across regions should be defined on accounting periods, which have unambiguous boundaries, rather than on timestamps.
A checklist for any date filter you write
- 1.Is this a business question or an audit question? Business means
trandateor posting period. Audit meanscreateddateorlastmodifieddate. - 2.Does the answer have to tie to the GL? If yes, join
accountingperiodonpostingperiodand stop usingtrandate. - 3.Is the range half-open?
>= startand< next start, always. - 4.Are adjustment and summary periods excluded?
isadjust,isquarterandisyearall filtered to'F'for a monthly series. - 5.Is the bound relative rather than hard-coded?
ADD_MONTHS(TRUNC(SYSDATE,'MM'), -6)never needs editing;'2026-01-01'needs editing in January. - 6.Have you formatted it? Multi-clause date logic is unreadable on one line — the SuiteQL formatter sorts that out in a second.
If the join syntax in the examples above needs unpacking, that is covered in SuiteQL join examples, and the period-alignment questions that come up at close are in the NetSuite month-end close checklist. Beginners should start with SuiteQL tutorial for beginners.
The reason date logic deserves to be visible
Nothing in this post throws an error. A BETWEEN that loses a day, a trandate filter where the period was meant, a fractional MONTHS_BETWEEN bucketing an invoice one band too early: all of them return a confident number. That is why a number without its query attached is not worth much.
prints the SuiteQL under every answer for exactly this reason. Ask what did we invoice by period for the last six months and you can read whether it used posting period or transaction date, whether adjustment periods were excluded, and whether the range was half-open — before you paste the figure into a board deck. Read-only by default, NetSuite-first, running as a dedicated instance you can self-host.
Frequently asked questions
How do I write a date in a SuiteQL query?
Use TO_DATE with an explicit format mask: TO_DATE('2026-06-30', 'YYYY-MM-DD'). Passing the mask means the query does not depend on a session or account date-format setting, so it behaves the same for every user and in every environment. Avoid bare string comparisons against date columns.
Why does my SuiteQL date filter miss records?
Usually BETWEEN on a column that carries a time. createddate and lastmodifieddate include a time component, so BETWEEN 1 June and 30 June excludes everything created after midnight on the 30th. Use a half-open range instead: >= the start date AND < the day after your intended end date.
What is the difference between trandate and postingperiod in SuiteQL?
trandate is the date on the document. postingperiod is the accounting period the GL entry landed in, joined to the accountingperiod table. They differ whenever a transaction is entered after its month closed. Financial reporting that must tie to the trial balance filters on posting period, not transaction date.
How do I get the current date in SuiteQL?
SYSDATE returns the current date and time. Wrap it in TRUNC to get midnight today, which is what you want for day arithmetic — TRUNC(SYSDATE) - 30 is thirty days ago. Bare SYSDATE in a subtraction gives a fractional day count that mis-buckets anything measured in whole days.
How do I group SuiteQL results by month?
Group by TRUNC(trandate, 'MM'), which returns the first day of the month, and add TO_CHAR(trandate, 'YYYY-MM') as a label that sorts correctly. Use 'Q' for quarters and 'YYYY' for years. Note that calendar months only match fiscal periods when your fiscal year is calendar-aligned with twelve equal months.
Does SuiteQL have a working days or business days function?
No. There is no built-in working-day calculation, so you either maintain a calendar helper table with a flag per date and join to it, or compute business days after the query returns. Calendar-day aging overstates performance across weekends and public holidays, which matters most for lead time and days-overdue metrics.
Calculators for this
Find the fiscal year, quarter and period for any date from your fiscal year start month. Supports 4-4-5 retail calendars and lists every period boundary.
Count business days between two dates, excluding weekends and your own holiday list. Also works in reverse: find the date that is N working days out.
Format SuiteQL and SQL in your browser: keyword casing, clause line breaks, indented joins and subqueries, plus a one-line minify mode.
Paste your open invoices with days overdue to get AR aging buckets, percentage mix, weighted average days overdue and a suggested bad debt reserve.
Keep reading
A SuiteQL tutorial for beginners: where to run queries, how the tables map to records, and eight worked queries you can paste in today.
Worked SuiteQL join examples: transaction to transactionline to customer, item and custom record joins, plus when left joins change the answer.
An ordered NetSuite month-end close checklist: 14 steps with the period-locking, inventory-costing, revenue and intercompany gotchas that break a close.
SuiteQL vs saved search in NetSuite: what each does better, a side-by-side table, where saved searches still win, and how to port one to SQL.