New to Attio?Get 10% off when you sign up through Craftt.Try Attio free →
All articles

The year(), quarter() and month() functions in Attio: extractors turn a date into a bucket, and buckets repeat

Written by

Published 22 min readTested in live Attio workspaces
Contents18 sections
  1. What year(), quarter() and month() do
  2. An extractor is a bucket label, not a date
  3. The collision problem: every January is a 1
  4. Making a key that does not collide
  5. Never subtract two extractions
  6. The one exception, and why it is not what you think
  7. quarter() is calendar, and Attio's docs are not sure either
  8. Building a fiscal quarter
  9. Extract to compute, format to display
  10. The quarter label that sorts wrong
  11. Every extractor reads UTC, and year() is the expensive one
  12. There is no week() and no weekday()
  13. minute() and second(), honestly
  14. Time of day as one number
  15. CRM use cases
  16. Copy-paste formulas
  17. Gotchas
  18. Final thoughts

Attio's formula library grew again. Alongside the day() and hour() that have been there all along, the date and time category now has year(), quarter(), month(), minute() and second() — five more functions that pull one component out of a date and hand it back as a number.

They are the easiest functions in the library to use and among the easiest to misuse, because they are filed under "date and time functions" and they are not really date functions at all.

A date function takes a date and gives you back something you can still do date things with. dateAdd() returns a date. eomonth() returns a date. min() over a set of dates returns a date. An extractor does the opposite: it takes a moment and returns a number that has been cut loose from the timeline. month({Close date}) does not give you March 2026. It gives you 3. March 2026, March 2025 and March 2019 all give you 3, and once you have the 3 there is nothing in it that can tell you which.

That single property — the timeline is gone, and what is left repeats — explains every way these functions go wrong.

This is part of our function-by-function series on Attio formula attributes — previously: if(), timeSpentIn(), dateDiff(), the ?? operator, count(), hasBeenIn(), valueSetAt(), contains(), sum(), previousValue(), dateAdd(), formatDate(), min()/max(), valueAt(), replace()/replaceAll(), avg()/median(), round()/ceil()/floor(), and()/or()/not(), length(), unique(), abs(), today()/now(), eomonth(), day()/hour(), mod(), power(), exp()/log(), split()/splitPart(), startsWith()/endsWith(), lower()/upper(), left()/right(), date()/timestamp(), setTimezone(), and number()/text().

color:var(--color-text-heading)]">Prefer to watch? Every function in this series gets its own short video in the [Every Attio formula function playlist. The whole library is also being built end to end in our first live session — Attio Formulas: Every Function, Built Live, free, no registration, Thursday 8 October 2026.

What year(), quarter() and month() do

Each takes a date and returns a number:

year(date)        →  a four-digit number, e.g. 2024
quarter(date)     →  1, 2, 3 or 4
month(date)       →  1 through 12
minute(date)      →  0 through 59
second(date)      →  0 through 59

Attio's own examples, all from 2024-03-15: year() returns 2024, quarter() returns 1, month() returns 3. minute(timestamp("2024-03-15T14:30:00Z")) returns 30, and second(timestamp("2024-03-15T14:30:45Z")) returns 45.

Two things to notice before you use any of them. First, the return type is always color:var(--color-text-heading)]">number, never text and never a date — so set the formula attribute's output type to Number rather than leaving it on Auto. Second, month() returns 3, not March. There is no month-name function in the library. If a human is going to read the column, you want [formatDate(), not month().

An extractor is a bucket label, not a date

The useful mental model is that Attio's date functions split into two families that answer two different kinds of question.

One family treats time as a line. dateDiff() measures distance along it, dateAdd() walks along it, min() and max() pick the ends of it, eomonth() jumps to a landmark on it. Everything they return is still anchored — you can keep going.

The other family treats time as a cycle. year(), quarter(), month(), day(), hour(), minute() and second() answer "whereabouts in the repeating pattern did this land?" They return a position in a cycle, and a position in a cycle is a label, not a location.

This is not a semantic distinction. It is the whole reason the extractors exist, and it tells you when to reach for them. Anything recurring — seasonality, anniversaries, renewal months, time-of-day patterns, fiscal periods, "which deals close in Q4 every year" — is a cycle question, and dateDiff() genuinely cannot answer it. Anything elapsed — age, sales cycle, days to renewal, time in stage — is a line question, and an extractor genuinely cannot answer it.

Most extractor bugs are someone using a cycle function to answer a line question, or the reverse.

The collision problem: every January is a 1

Here is the first and most common failure, and it ships silently.

You want revenue by month, so you build a formula attribute:

month({Close date})

It works. Every deal gets a number from 1 to 12. You group a report by it, and the chart is wrong — not visibly wrong, not blank, just quietly summing three years of January into one bar labelled 1.

month() returned the month. It did not return *which* month. The year was never part of the value, so there is nothing to separate 2024's January from 2026's. Same for quarter(): every Q1 you have ever had is a 1. Only year() is safe on its own, because year is the top of the hierarchy and nothing above it can vary.

This is worth stating plainly because the collision looks like a *result*. A twelve-bar chart of monthly revenue is exactly what a seasonality analysis is supposed to look like — which is why, if you actually wanted seasonality, month() on its own is correct and this is a feature. The bug is when you wanted a time series and got a seasonality chart instead. They are visually indistinguishable if you do not read the axis.

The test: ask whether two records a year apart should share a bucket. If yes, use the bare extractor. If no, you need a composite.

Making a key that does not collide

To keep the year, put it back in. There are two shapes, and they do different jobs.

A sortable key multiplies the outer unit out of the way and adds the inner one:

year({Close date}) * 100 + month({Close date})

March 2026 becomes 202603, December 2025 becomes 202512, and the numbers sort in chronological order because the year dominates. The quarter version is the same trick with a smaller multiplier:

year({Close date}) * 10 + quarter({Close date})

20261 for Q1 2026, 20264 for Q4. Both are unique per period and both sort correctly.

One honest caveat that most guides skip: 202603 is a label made of digits, not a quantity. Adding 1 to December 2025 gives 202513, which is not a month. Do not do arithmetic on these keys — sort and group only.

If you *do* need arithmetic, use the second shape, a continuous count with no gaps:

year({Close date}) * 12 + month({Close date})

That is months since year zero. It is meaningless as a display value, but the difference between two of them is real:

(year(today()) * 12 + month(today())) - (year({Close date}) * 12 + month({Close date}))

That is the number of calendar months between the close date and today — a different and often more useful quantity than dateDiff(..., "months"), which measures elapsed time rather than crossings. If you are building cohort ages, invoice periods or "month 1, month 2, month 3" retention buckets, the calendar-crossing count is the one the finance team means.

Never subtract two extractions

The composite key above is the safe version of an instinct that is unsafe in its raw form. The raw form is subtracting two extractions to measure a gap:

month({Closed on}) - month({Created at})

This is correct roughly 90% of the time and wrong on exactly the records you will not check. A deal created in November and closed in February returns 2 - 11, which is -9. The sales cycle did not take negative nine months.

The same failure runs the whole family, one level at a time:

  • minute(end) - minute(start) breaks across an hour: 14:58 to 15:03 gives 3 - 58, or -55.
  • hour(end) - hour(start) breaks across a day.
  • day(end) - day(start) breaks across a month.
  • month() and quarter() break across a year.

There is one rule underneath all of it: an extraction is only comparable to another extraction from the same larger unit. Cycles wrap, and subtraction does not know that.

Durations belong to dateDiff(), which is built for exactly this, handles every boundary, and returns the absolute difference so you cannot get a negative sales cycle by accident.

The one exception, and why it is not what you think

year(a) - year(b) never wraps, because there is no cycle above the year. So the subtraction is arithmetically safe. It is still not what most people want it for.

year(today()) - year({Founded})

That does not give you the company's age. It gives you the number of New Year's Eves between then and now. A company founded on 31 December 2024 returns 2 in January 2026, having existed for thirteen months. A company founded on 1 January 2024 returns the same 2, having existed for twenty-four. The error is up to a full year and it is largest at the boundary, which is where the records cluster.

"Calendar years crossed" is a real quantity, and occasionally the one you want — how many fiscal years does this contract touch, how many annual reports will mention this deal. But if the word you would use out loud is *age*, *tenure*, *cycle* or *ago*, it is a line question and dateDiff() owns it.

quarter() is calendar, and Attio's docs are not sure either

quarter() returns the calendar quarter. January to March is 1, always, for everyone.

That is a problem for a large share of companies, because plenty of fiscal years do not start in January — April in the UK and Japan, October for the US federal government and many of its contractors, July in Australia, February for a lot of retail. For any of them, quarter() is off by one or two quarters on every record, and there is no second argument to shift it.

There is a small piece of evidence that Attio knows this is a loose end. On the live formula functions library page, the quarter() row reads:

Extracts the calendar quarter from a date as a number from 1 to 4. followed, inside a code chip, by [CONFIRM: calendar quarters, not fiscal]

That is an internal editorial note that was never removed before publishing — verified on the live page on 25 September 2026. It is also the only place the word "fiscal" appears anywhere on that page. Take it as a fair warning: the behaviour is calendar-only, nobody has documented a fiscal option, and you should not wait for one.

Building a fiscal quarter

You do not need quarter() for this. You need month() and a rotation.

Pick the month your fiscal year starts — call it F, so April is 4. Rotate the calendar month into a fiscal month index from 1 to 12, then divide into threes and round up:

ceil((mod(month({Close date}) - 4 + 12, 12) + 1) / 3)

Swap the 4 for your own fiscal start month and that is the whole formula. Walking through it with an April start: April is mod(0 + 12, 12) + 1 = 1, so ceil(1 / 3) = 1. March is mod(-1 + 12, 12) + 1 = 12, so ceil(12 / 3) = 4. Q4 ends in March, which is right.

The 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">+ 12 is doing real work. It keeps the dividend positive before it reaches [mod(), so you never depend on how a given system handles the remainder of a negative number. It costs nothing and removes a class of bug you would otherwise only find in one quarter of the year.

The fiscal year needs a decision from you, not from the formula: does FY26 mean the year it starts in or the year it ends in? For a fiscal year named after the year it ends in — the usual convention for April and October starts:

if(month({Close date}) >= 4, year({Close date}) + 1, year({Close date}))

And the two combine into a sortable fiscal period key the same way as before:

if(month({Close date}) >= 4, year({Close date}) + 1, year({Close date})) * 10
  + ceil((mod(month({Close date}) - 4 + 12, 12) + 1) / 3)

Write it out inline like that, in one formula attribute. It is tempting to build "Fiscal year" and "Fiscal quarter" as separate attributes and multiply them together in a third, but a formula attribute that references another formula attribute is unreliable in Attio — it returns wrong values with no error, including on records where the referenced field is blank. Inline the base expression every time.

Extract to compute, format to display

quarter() and formatDate() overlap, and the difference between them is the difference between a number and a label.

You wantUseYou getGood for
To compare or branch on the quarterquarter({Close date})1if(), filters, thresholds, arithmetic
A readable quarter on a report axisformatDate({Close date}, "yyyy 'Q'Q")2026 Q1Grouping, charts, anything a human reads
To compare or branch on the monthmonth({Close date})3Anniversary and seasonality logic
A readable monthformatDate({Close date}, "MMMM yyyy")March 2026Reports, exports, record pages

The rule is short: extract when a formula is going to consume it, format when a person is going to read it. A view full of the number 3 tells nobody anything. A text label cannot be compared with >= or fed into arithmetic.

And because 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">quarter() returns a number, it slots into [comparison logic that a label cannot:

if(quarter({Close date}) == quarter(today()), "This quarter", "Other")

Note the shape of that condition: comparing quarter to quarter is only meaningful *within* the same year, which is the collision problem wearing a different hat. If the records span years, compare the composite key instead.

The quarter label that sorts wrong

There is a specific trap in the display half of that table, and it is worth its own section because it is invisible until a chart is in front of a customer.

Attio's own docs suggest 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">"Q[Q] YYYY" as the quarter token pattern. Two problems. The tokens are case-sensitive [date-fns style, so uppercase YYYY is the ISO week-year, not the calendar year, and literal letters have to be escaped in single quotes — the working pattern is "'Q'Q yyyy", which we covered when the formatDate article was corrected.

The second problem survives even after you fix the tokens. "'Q'Q yyyy" produces Q1 2026, and that is text, so it sorts alphabetically:

Q1 2025
Q1 2026
Q2 2025
Q2 2026

Every Q1 in history sorts before every Q2 in history. A chart ordered by that axis is not in chronological order, and unlike the month() collision it does not merge anything — it just shuffles, which is harder to spot.

Put the year first and it sorts correctly while reading just as well:

formatDate({Close date}, "yyyy 'Q'Q")
2025 Q1
2025 Q2
2026 Q1
2026 Q2

Same information, same readability, correct order, one token swap. The same logic is why "yyyy-MM" is the right monthly label and "MMMM yyyy" is not — March 2026 sorts under A for April.

Every extractor reads UTC, and year() is the expensive one

This one is inherited from setTimezone(), and it lands hardest here.

Start with the part that saves you work: if the attribute is a date, there is no timezone problem at all. A date has no instant attached — a contract start of 1 April is 1 April in every office — so month(), quarter() and year() over a date attribute are simply right.

If the attribute is a timestamp, it is a point on the global timeline, and Attio carries raw timestamps in UTC. The extractors are not hard-wired to UTC; they read whatever zone the value is carried in, and that zone is UTC until you change it. So every bucket boundary is a UTC boundary.

For hour() that is a nuisance. For year() it is a restatement:

A deal marked closed at 7pm on 31 December in New York is already 1 January in UTC. year() returns the following year. The revenue lands in the wrong fiscal year, on the last day of the year, on a deal that somebody almost certainly pushed to close before the deadline. The bias is not random — end-of-period deals cluster in the evening, which is exactly the window UTC pushes forward — so the error concentrates precisely in the records that get the most scrutiny.

The fix is one wrap, on the inside:

year(setTimezone({Closed on}, "America/New_York"))
quarter(setTimezone({Closed on}, "America/New_York"))

Innermost, always, and consistently. The dangerous version is the half-converted formula where one branch is wrapped and another is not — it classifies most records correctly and ships.

And the reassuring half of the rule, worth repeating because it stops people wrapping things pointlessly: timezones move boundaries, never durations. dateDiff(), dateAdd(), min(), max() and every comparison are unaffected. It is only the extractors and formatDate() that change their answer.

There is no week() and no weekday()

Worth saying out loud, because it is the cycle most operations teams actually report on.

Attio has year(), quarter(), month(), day(), hour(), minute() and second(). It has no week() and no weekday(). The word "week" appears on the functions page only as a unit string inside dateDiff() and timeSpentIn().

Day-of-week is rebuildable with arithmetic — pick a known Monday, count days, take the remainder — which we worked through in the mod() article:

mod(dateDiff(date("2024-01-01"), {Created at}, "days"), 7)

Week-of-year is the harder one, and if you reach for a formatDate() week token, test it on a record in the first days of January. That is where the ISO week-year and the calendar year disagree — the 1st of January can belong to week 52 of the previous year — and it is the one place the usually-wrong uppercase YYYY may be the correct token rather than a mistake. Verify on a real record before you trust a January number.

minute() and second(), honestly

Most articles would invent a use case here. There is not much of one.

There is no CRM question whose answer is the second of the minute. second() exists for completeness, and the only place it earns its keep is debugging — confirming that two timestamps that look identical in a view are actually different, or that an imported time did not get truncated to the minute.

minute() is slightly better off but still narrow, and it comes with the family's sharpest instance of the subtraction trap. Response time is the obvious temptation:

minute({First reply}) - minute({Lead created})

That is wrong in every case that crosses an hour, which is most of the slow ones — meaning the formula is most wrong exactly where the SLA breach is. dateDiff({Lead created}, {First reply}, "minutes") is the right tool and always was.

Time of day as one number

There is one genuinely good job for minute(), and it is a composite, the same shape as the month key:

hour({Lead created}) * 60 + minute({Lead created})

That is minutes since midnight — a single sortable number from 0 to 1439 that represents a time of day independently of which day it was. It lets you ask questions that neither a timestamp nor dateDiff() can express, because they are cycle questions:

(hour(setTimezone({Lead created}, "Europe/London")) * 60
  + minute(setTimezone({Lead created}, "Europe/London"))) >= 540

That is "arrived at or after 9:00am London time". Pair it with an upper bound at 1020 for 5pm and you have an inside-business-hours flag, which is the honest denominator for any response-time SLA — you cannot fairly count the eleven hours a lead sat overnight against a team that was asleep.

Note the setTimezone() on both halves. A time-of-day number built on UTC is a time-of-day number for a team that does not exist.

CRM use cases

  • Close month, correctly keyed — year({Close date}) * 100 + month({Close date}) — important because it is the one monthly grouping that will not merge last year's January into this year's.
  • Fiscal quarter — ceil((mod(month({Close date}) - 4 + 12, 12) + 1) / 3) — important because quarter() is calendar-only and your board pack is not.
  • Renewal month — month({Renewal date}) — important because this is a genuine cycle question: it tells CS which month of every year an account comes up, and the collision is the point.
  • Seasonality by month — month({Close date}) grouped in a report — important because deliberately throwing the year away is how you see a pattern that only exists across years.
  • Cohort age in calendar months — (year(today()) * 12 + month(today())) - (year({Created at}) * 12 + month({Created at})) — important because month-1 / month-2 retention buckets count calendar crossings, not 30-day blocks.
  • Anniversary this month — month({Contract start}) == month(today()) — important because it is the only shape of question dateDiff() cannot answer at all.
  • Year closed, in your timezone — year(setTimezone({Closed on}, "America/New_York")) — important because the last evening of December is where revenue quietly changes fiscal year.
  • Inside business hours — hour(setTimezone({Lead created}, "Europe/London")) * 60 + minute(setTimezone({Lead created}, "Europe/London")) — important because an SLA measured across overnight hours is measuring the clock, not the team.

Copy-paste formulas

Swap in your attribute names and these work as-is.

Sortable month key (Number output):

year({Close date}) * 100 + month({Close date})

Sortable quarter key (Number output):

year({Close date}) * 10 + quarter({Close date})

Fiscal quarter, fiscal year starting in April (Number output):

ceil((mod(month({Close date}) - 4 + 12, 12) + 1) / 3)

Fiscal year named after the year it ends in (Number output):

if(month({Close date}) >= 4, year({Close date}) + 1, year({Close date}))

Calendar months since created (Number output):

(year(today()) * 12 + month(today())) - (year({Created at}) * 12 + month({Created at}))

Year in a reporting timezone (Number output):

year(setTimezone({Closed on}, "America/New_York"))

Readable quarter label that sorts correctly (Text output):

formatDate({Close date}, "yyyy 'Q'Q")

Minutes since midnight, localised (Number output):

hour(setTimezone({Lead created}, "Europe/London")) * 60
  + minute(setTimezone({Lead created}, "Europe/London"))

Anniversary flag (Checkbox output):

month({Contract start}) == month(today())

Gotchas

  • month() and quarter() collide across years. They return a position in a cycle, not a period. Use a composite key whenever two records a year apart should not share a bucket.
  • color:var(--color-text-heading)]">Subtracting two extractions is wrong at every boundary. Use [dateDiff() for anything elapsed. year(a) - year(b) is the only arithmetically safe one, and it counts New Year's Eves, not age.
  • quarter() is calendar-only and has no fiscal argument. Attio's own docs still carry an unresolved [CONFIRM: calendar quarters, not fiscal] note in that row.
  • Timestamps extract in UTC. Dates do not have the problem at all. Wrap timestamps in setTimezone(), innermost, and do it on every branch.
  • The output is a number, not a name. month() returns 3. Set the output type to Number, and use formatDate() for anything a person reads.
  • "'Q'Q yyyy" sorts alphabetically. Use "yyyy 'Q'Q" for any axis that needs chronological order.
  • Uppercase YYYY is ISO week-year, not the calendar year — a date-fns rule Attio's examples still get wrong.
  • color:var(--color-text-heading)]">Blank dates give blank numbers. Guard downstream arithmetic with [??, and decide deliberately what an undated record should do rather than letting it drop out of a filter unnoticed.
  • color:var(--color-text-heading)]">There is no week() or weekday(). Rebuild weekday with [mod(); verify any week-of-year format token on a record in early January.
  • Do not chain formula attributes. Build the fiscal key inline in one attribute — referencing another formula attribute returns wrong values silently.

Final thoughts

year(), quarter() and month() are not date functions with small print. They are grouping keys, and they are excellent at that job as long as you remember what they threw away to do it.

The one question to ask before every one of them: am I asking where this sits on the timeline, or where it sits in a repeating cycle? Timeline questions — how long, how far, what next — belong to dateDiff() and dateAdd(). Cycle questions — which month, which quarter, what time of day, is it the anniversary — belong here, and nothing else in the library can answer them. When you need both, you compose: extract the outer unit, multiply it out of the way, and add the inner one.

For the rest of the library — every history, logic, math, date, text and type conversion function with CRM use cases — see the complete guide to Attio formula attributes.

Next: random() — the one function in Attio's library that returns a different answer every time it runs, and why that makes it far more dangerous, and far more useful, than a dice roll.

And if you would rather have your fiscal calendar, cohort keys and reporting timezones designed and shipped correctly the first time, that is literally what we do. Get a free workspace audit or see the AI-native Attio sprint.

Official sources

Attio documentation used to verify this guide:

Need help with your Attio setup?

We migrate teams, build data models, wire automations, and train Claude agents inside your workspace. Discovery call is free.

Ready when you are.

Book a call and we will tell you honestly whether this is worth doing, or start with the free 48-hour audit and decide afterwards.