Get the Quarter of the Year from a Date in JavaScript

To get the quarter, compute Math.ceil(plainDate.month / 3) — 1 for Jan-Mar through 4 for Oct-Dec. Part of Date Arithmetic Without Mutations.

Why this scenario is tricky

The quarter is a derived quantity, not a field any date type stores, so every implementation reduces to a small piece of arithmetic on the month number — and the correctness of that arithmetic depends entirely on how the month is indexed. Legacy JavaScript trained a whole generation of developers to expect zero-based months, because Date.prototype.getMonth() returns 0 for January through 11 for December. Carry that habit into a quarter formula and Math.ceil(getMonth() / 3) yields 0 for January, 1 for April, and so on: every quarter is shifted down by one, and December collapses into a nonexistent "quarter 0" boundary case that is easy to miss because the mistake only surfaces at the edges of each three-month block.

Temporal deliberately breaks that habit. Temporal.PlainDate.month is one-based, matching how humans actually count months and how the ISO 8601 standard writes them, so January is 1 and December is 12. With one-based months the formula Math.ceil(month / 3) is exactly right with no correction term: January through March map to 1, April through June to 2, July through September to 3, and October through December to 4. The subtle takeaway is that the formula is identical in shape between legacy and modern code but produces different answers, which is precisely the kind of silent divergence that makes a migration dangerous if you copy the old arithmetic verbatim.

The only sharp edge here is month indexing: Temporal.PlainDate.month is 1-based (1 = January), so Math.ceil(month / 3) gives the quarter directly. Do the same maths on a legacy Date and getMonth()'s 0-based value shifts every quarter by one.

Mind the month indexinggetMonth() is 0-based and shifts quartersMind the month indexingceil(getMonth()/3)off by oneceil(plainDate.month/3)1..4 correctTemporal months are 1-based, so the quarter formula works directly.

Minimal working solution

The one-liner works because integer division rounded up partitions the twelve one-based months into four equal groups of three. It is worth reading the expression slowly the first time: plainDate.month is a number from 1 to 12, dividing by 3 gives a value from roughly 0.33 to 4, and Math.ceil rounds each of those up to the smallest integer that is at least as large, which lands every month in its correct quarter. Because the input is already a plain integer, there is no coercion, no time zone, and no clock involved — the calculation is pure arithmetic on a calendar field, which is exactly why it is deterministic regardless of where or when it runs.

If your starting point is a ZonedDateTime or an Instant rather than a PlainDate, extract the civil date first with toPlainDate() (or toZonedDateTimeISO(zone).toPlainDate() for an instant), so that the month you divide is the month in the calendar you actually care about. Reading .month off a zoned value already gives you the wall-clock month in that zone, but being explicit about the conversion keeps the intent visible and stops a reviewer from wondering which zone's month the quarter was computed in.

import { Temporal } from '@js-temporal/polyfill';
const q = Math.ceil(Temporal.PlainDate.from('2024-05-15').month / 3); // 2

Quarter from monthceil(month / 3) gives 1-4Quarter from monthPlainDateceil(month/3)quarter

Full production version

Most real uses of a quarter are not about the label 1–4 at all; they are about the reporting period the quarter names — the range of dates a financial summary, a cohort analysis, or a billing cycle should cover. That is why the production helper returns not just the quarter number but its start and end dates. Deriving the first month of the quarter as (q - 1) * 3 + 1 turns quarter 1 into month 1, quarter 2 into month 4, quarter 3 into month 7, and quarter 4 into month 10, and building a PlainDate on day 1 of that month gives the inclusive start. Adding three months with start.add({ months: 3 }) gives the exclusive end — the first day of the next quarter — which is the half-open convention used throughout this topic.

The half-open [start, end) shape matters because it makes period queries composable and boundary-safe. A row timestamped at exactly midnight on the first day of the next quarter belongs to that next quarter, not to the one that just ended, and a half-open range expresses that without the off-by-one ambiguity an inclusive end date invites. It also means adjacent quarters tile perfectly: the end of Q1 is literally the same instant as the start of Q2, so a sequence of quarterly ranges covers the year with no gaps and no overlaps. When you feed these bounds into a database query you compare with >= start AND < end, and every record falls into exactly one quarter.

Because PlainDate arithmetic is calendar-aware, the same helper works across year boundaries and leap years without special cases. Adding three months to October 1 correctly rolls into January 1 of the following year, and the day-1 anchoring means the varying lengths of the months inside the quarter never enter into it. That robustness is a direct consequence of doing the math on a civil-date type rather than on millisecond offsets, where month lengths and DST transitions would each be a potential source of drift.

import { Temporal } from '@js-temporal/polyfill';
function quarterBounds(d: Temporal.PlainDate) {
  const q = Math.ceil(d.month / 3);
  const start = Temporal.PlainDate.from({ year: d.year, month: (q - 1) * 3 + 1, day: 1 });
  return { quarter: q, start, end: start.add({ months: 3 }) }; // half-open
}

Quarter boundsDerive the quarter's start and endQuarter boundsPlainDatequarterstart month[start,end)

Verification snippet

The assertions that actually prove correctness are the ones at the seams of the quarters: the first and last month of each three-month block. Assert that January and March both map to quarter 1, that April and June both map to quarter 2, and that December maps to quarter 4 rather than a spurious quarter 5 — the December case is the one that catches an accidental zero-based month, because a zero-based formula would push it out of range. A single mid-quarter check like May → Q2 is reassuring but insufficient on its own, since an off-by-one error can still pass for a month that happens to sit in the interior of a block.

Beyond the label, test the bounds. Assert that the start of Q2 is April 1 and that its exclusive end is July 1, so the three-month span is exactly right and the half-open convention holds. Add a year-boundary case — the end of Q4 should be January 1 of the following year — to prove the calendar arithmetic rolls over correctly. Finally, if your inputs can arrive as zoned values, assert that computing the quarter under several TZ settings yields the same answer once you have reduced to a PlainDate, which demonstrates that no host-zone assumption leaked into the calculation.

Quarter of the Year assertionsKey cases assert correctlyAssertions that prove the edge caseMayQ2JanuaryQ1DecemberQ4Q2 startApril 1

Common pitfalls

The headline pitfall is the zero-based month already discussed: reusing a legacy getMonth()-based formula on data that is now one-based, or vice versa, shifts every quarter by one. When migrating, audit every quarter calculation and standardize on Temporal.PlainDate.month, because a mixed codebase where some functions are zero-based and others one-based produces reports that are subtly and inconsistently wrong. The second common mistake is an inclusive end date for the quarter's range, which makes a record dated on the quarter boundary count in two quarters at once and inflates period totals; the half-open range avoids it.

A third pitfall is conflating the calendar quarter with a fiscal quarter. Many organizations run a fiscal year that does not start in January — a common one begins in April, another in October — and for them "Q1" means the first quarter of that fiscal year, not January through March. If your domain uses a fiscal calendar, the fix is to shift the month by the fiscal-year offset before applying Math.ceil(month / 3), wrapping around 12 as needed, and to derive the period bounds from the fiscal start. Hard-coding the calendar quarter and labeling it as the fiscal one is a classic source of finance-team confusion, so make the convention explicit in the function name and its documentation.

A final subtlety is week-of-quarter or day-of-quarter calculations that some dashboards need. Those are straightforward once you have the quarter's start date — subtract it from the target date with until — but they go wrong if you compute them from the calendar-year start instead. Anchor any within-quarter arithmetic to the quarter's own start, and the numbers stay consistent with the period the user is looking at.

Quarter of the Year pitfallsCommon mistakes and their fixesWrongRightceil(getMonth()/3)0-based → off by oneceil(month/3) on Temporal1-based, correctinclusive endoverlaps next quarterhalf-open boundsclean periods

Frequently Asked Questions

How do I get the quarter of the year from a date?

Compute Math.ceil(plainDate.month / 3) on a Temporal.PlainDate. Because Temporal months are 1-based, this returns 1 for January-March up to 4 for October-December without any adjustment.

How do I get the start and end of a quarter?

Derive the quarter with Math.ceil(month / 3), build a PlainDate for day 1 of that quarter's first month ((q-1)*3+1), and add three months for the exclusive end. Query the half-open range [start, end) for the reporting period.

How do I compute a fiscal quarter that does not start in January?

Shift the month by your fiscal-year offset before applying the formula. If the fiscal year starts in April, subtract 3 (wrapping around 12) so April becomes month 1, then compute Math.ceil(shiftedMonth / 3). Derive the period bounds from the fiscal start rather than January 1. Keep the calendar-versus-fiscal convention explicit in the function name so no one assumes the wrong one.

Why is Math.ceil(month / 3) correct for Temporal but wrong for legacy Date?

Temporal.PlainDate.month is one-based (January = 1), so dividing 1–12 by 3 and rounding up lands each month in quarters 1–4 exactly. Legacy Date.getMonth() is zero-based (January = 0), so the same formula returns 0 for January and shifts every quarter down by one. The expressions look identical but the indexing differs, which is why copying legacy quarter arithmetic into Temporal code silently breaks it.

How do I get every date in a quarter?

Get the quarter's half-open bounds with the production helper, then enumerate from the start date, advancing with PlainDate.add({ days: 1 }) while the cursor is before the exclusive end. Because the stepping runs on a zoneless calendar type, the day list is identical regardless of the host machine's time zone, which is the same determinism that makes the quarter label itself host-independent.