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

The setTimezone() function in Attio: why your reports are bucketed in UTC, and what that quietly costs

Written by

Published 21 min readTested in live Attio workspaces
Contents17 sections
  1. What setTimezone() does
  2. The moment does not move
  3. Timezones matter for boundaries, never for durations
  4. Why hour() reads in UTC
  5. The day boundary is the whole problem
  6. Quarters, years, and the deal that closed in the wrong one
  7. Put setTimezone() innermost
  8. Use zone names, never offsets
  9. Why dateAdd() is not a substitute
  10. Today, in your own timezone
  11. Three different timezone questions
  12. Do not feed it a date
  13. How to test this without fooling yourself
  14. CRM use cases
  15. Copy-paste formulas
  16. Gotchas
  17. Final thoughts

setTimezone() is the only function in Attio's library that has anything to say about timezones, and it is almost always reached for by someone trying to solve the wrong problem.

The usual reason people find it is display: a meeting time in a table reads as 21:00 and everyone knows the call is at 4pm, so somebody goes looking for the function that will make the cell say 4pm. That is a reasonable instinct and it is not really what this function is for.

setTimezone() earns its place somewhere less obvious. Every report you build on a timestamp — deals closed this quarter, leads created today, meetings booked this week — is quietly bucketed in UTC, and for most teams on earth that means the boundaries of those buckets fall somewhere in the middle of the working day. This is the function that moves them.

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(), and date()/timestamp().

color:var(--color-text-heading)]">Prefer to watch? There is a short video for this one: [Attio setTimezone(): show meeting times in the customer's timezone. 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 setTimezone() does

The signature is two arguments:

setTimezone(date, timezone)

Attio's documentation describes it as converting a date or timestamp to the specified timezone, returning a timestamp, and gives one example:

setTimezone(now(), "Europe/London")

Two things are worth pulling out of that one line before anything else.

The first is that the return type is always a timestamp, whatever you put in. Give it a date and you get a timestamp back. That has consequences, and we will come to them.

The second is the shape of the second argument. "Europe/London" is an IANA timezone name — a region and a city, not an offset. That choice is doing more work than it looks like it is doing, and it is the difference between a formula that is right all year and one that is right until the clocks change.

The moment does not move

The name is the most misleading thing about this function.

"Set timezone" sounds like an assignment. It sounds like the value has a timezone property and you are writing a new one into it, the way you would set a label. Plenty of languages have a function that does exactly that, and it is genuinely useful when a system hands you a wall-clock time with no zone attached and you happen to know where it came from.

This is not that function. Attio's own wording is precise: it converts to the specified timezone. The moment stays exactly where it was on the global timeline. A call that happened at a particular instant still happened at that instant, whether you express it as 21:00 in London or 16:00 in New York or 06:00 tomorrow in Sydney. Those are three descriptions of one event.

So setTimezone() never changes *when*. It changes *how the when is written down* — and since every other date function in Attio works by reading the written-down version, that turns out to be the only thing you need.

The practical version of this rule: if a formula's answer would be the same regardless of which timezone the event is described in, setTimezone() cannot help you. If the answer depends on which day or hour the event lands in, setTimezone() is the only thing that can.

Timezones matter for boundaries, never for durations

That sorts the whole library into two piles, and the sorting is clean enough to memorise.

FunctionDoes wrapping the input in setTimezone() change the answer?Why
hour(), minute(), second()YesThese read a clock face, and clock faces are local
day(), month(), quarter(), year()YesThese read a calendar, and calendars roll over locally
formatDate()YesIt renders the same components the extractors read
eomonth()At the edgesA timestamp near a month boundary can belong to either month
dateDiff()NoElapsed time is the same from every vantage point
dateAdd()NoAdding seven days to an instant gives one instant
<, >, == against another timestampNoOrdering on the timeline is absolute
min() / max() over timestampsNoEarliest is earliest everywhere

The left column is everything that chops continuous time into named buckets. Buckets have edges, edges are drawn on a local calendar, and a local calendar is a timezone.

The right column is everything that measures distance. Distance has no edges. Two events are eleven hours apart in Tokyo and eleven hours apart in Chicago.

This is why the display instinct that brings people to this function leads them astray. They wrap the meeting time to make it read correctly, which may or may not do what they want to the cell, and then they leave it out of the report that counts meetings per day, which is the place it would actually have changed a number.

Why hour() reads in UTC

Our day() and hour() article noted a behaviour that trips people up constantly: those functions read timestamps in UTC. A meeting at 4pm New York time returns 20 from hour(), not 16.

Attio's documentation does not mention this anywhere. The entry for hour() says only that it extracts the hour from a date or timestamp as a number from 0 to 23, and the single place "UTC" appears on the entire functions page is a note about now() recalculating around midnight UTC.

The cleanest way to hold this is that the extractors are not hard-wired to UTC at all. They read whatever timezone the value in front of them is carried in — and a raw timestamp attribute in Attio is carried in UTC, so that is what they report. setTimezone() is the one lever that changes what is in front of them:

hour({Next meeting})                                    →   20
hour(setTimezone({Next meeting}, "America/New_York"))   →   16

That reframing matters because it tells you where the fix goes. There is no setting to change, no workspace preference that will quietly correct the number, and no way to tell hour() which zone you meant. You convert the value, or you get UTC.

The day boundary is the whole problem

Everything above sounds academic until you count something per day.

UTC midnight is not midnight for almost anybody. For a New York team it falls at 8pm the previous evening, so every call logged, lead created or deal closed between 8pm and midnight lands on tomorrow's count. That is annoying and mostly survivable: it shifts a slice of the evening into the next bucket, and the shape of the week still looks roughly right.

For a Sydney team the same boundary falls at 10am or 11am local, depending on the season. The "day" your report is counting starts in the middle of the morning and ends in the middle of the next morning. A Tuesday in that report is the back two-thirds of Tuesday plus the first third of Wednesday. Nobody looking at the chart can see this, and every conversation about it — why does Monday look light, why did that lead land on the wrong day — has a different, plausible, wrong explanation available.

The same logic runs in reverse across the Pacific. For a Los Angeles team, UTC midnight is 4pm or 5pm local, so the last two hours of every working day belong to tomorrow.

This is the single highest-value use of the function, and it is the one nobody goes looking for, because the symptom is not an error. The symptom is a number that is slightly wrong in a way that always has a story attached.

day(setTimezone({Created at}, "Australia/Sydney"))

Quarters, years, and the deal that closed in the wrong one

Scale the same problem up and the stakes change.

Take a deal marked closed at 8pm on 31 December, New York time. Locally that is unambiguously the last day of the year. In UTC it is 1 January, so year() reports the next year, quarter() reports Q1, and formatDate({Closed on}, "'Q'Q yyyy") prints a quarter label that disagrees with the person who closed the deal.

One deal does not matter. The pattern does, because deals do not close at uniformly random times. They cluster at the end of the quarter, in the evening, under pressure — which is precisely the window that UTC pushes into the next period. The bias is not random and it always runs the same direction.

quarter(setTimezone({Closed on}, "America/New_York"))
formatDate(setTimezone({Closed on}, "America/New_York"), "'Q'Q yyyy")

Worth restating from the date() and timestamp() article: a date attribute has no timezone and cannot have this problem. A close date is a label — the same calendar day everywhere — and if it is stored as a date rather than a timestamp, none of this applies and setTimezone() has nothing to do. The best use of this function is often the one you avoid by choosing the right type in the first place.

Put setTimezone() innermost

There is only one correct position for it in an expression, and it is the bottom.

hour(setTimezone({Created at}, "America/Chicago"))

Not the other way around. setTimezone(hour({Created at}), "America/Chicago") asks the function to convert the number 20 to Chicago time, which is not a question.

The reason to state something this obvious is that it is the same structural rule as the one in our lower() and upper() article: normalisation belongs at the bottom of the expression tree, so that everything above it inherits the normalised value. Case normalisation goes innermost so contains() and startsWith() see lowered text. Timezone conversion goes innermost so hour() and quarter() see local time. Both are the same move.

The trap is a long formula where the conversion is applied to one branch and not another:

if(
  hour(setTimezone({Created at}, "America/Chicago")) < 9,
  "Out of hours",
  if(hour({Created at}) > 18, "Out of hours", "Business hours")
)

That formula is half-converted. It will classify a large fraction of records correctly, which is the worst possible outcome, because it is the version that gets shipped. If a timestamp appears more than once in a formula, it needs the same wrapper every time.

Use zone names, never offsets

The documented example uses "Europe/London", and that form is doing something an offset cannot.

"Europe/London" does not mean +00:00. It means *whatever London is doing on the date of this particular value* — GMT from late October to late March, BST from late March to late October. The conversion is therefore date-dependent: run the same formula across a year of timestamps and the shift is one hour for some of them and zero for others. That looks like inconsistency and it is the function being correct.

An offset cannot express that. If you freeze +00:00, every summer timestamp is an hour out; if you freeze +01:00, every winter one is. There is no single number that is right for London all year, and the same is true of nearly all of Europe, North America, Australia and New Zealand.

Zone names also handle the offsets that are not whole hours at all. India is +5:30, South Australia is +9:30, Nepal is +5:45, Chatham Island is +12:45. A region name expresses those without you needing to know they exist.

Use "America/New_York", not "EST". Use "Europe/London", not "GMT". The abbreviations are ambiguous — there is more than one CST in the world — and half of them name one side of a daylight-saving rule rather than a place.

Why dateAdd() is not a substitute

Before finding setTimezone(), most people solve this with subtraction:

hour(dateAdd({Created at}, -5, "hours"))

It works. You check a record, the number comes out right, you move on. It is also wrong in two separate ways, and both of them are invisible on the day you test it.

It is wrong twice a year, on every record, from the moment the clocks change. A minus-five shift that was correct in January is an hour out from mid-March, and the drift arrives silently on records that were already fine. Worse, it is wrong *retroactively* — a formula attribute recomputes, so last winter's records get re-evaluated under this summer's constant.

It is wrong all year for any zone whose offset is not a whole number of hours. dateAdd() takes a number and a unit; you can shift by minutes instead to reach +5:30, at which point you are hand-encoding India's offset as 330 in a formula that nobody will ever be able to read.

And it is fragile in a way that is hard to audit. Five months from now, -5 in a formula is a number with no explanation. "America/New_York" explains itself.

The rule is simple: if you are doing hour arithmetic on a timestamp in order to make a local time, you are reimplementing a timezone database badly. Use the function.

Today, in your own timezone

Attio's 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">today() returns the current date with no time component, and like now() it is anchored to UTC — the [today() and now() article covers its recalculation behaviour. That means today() is *today in UTC*, which for the Sydney team above flips over mid-morning and for the Los Angeles team flips at teatime.

For most "days until renewal" arithmetic that is fine; being off by one day for part of the day rarely changes a decision. For anything where the boundary itself is the point — an SLA due today, an overdue flag, a task-due-date comparison — it does.

There is no localised today() in the library, but you can build one out of three functions you already have:

date(formatDate(setTimezone(now(), "Australia/Sydney"), "yyyy-MM-dd"))

Read it inside out. 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">now() gives the current instant. setTimezone() re-expresses it in Sydney. formatDate() writes out the local calendar date as text — and because it is reading a converted value, the date it writes is the Sydney date, not the UTC one. date() parses that text back into a real date type, which is exactly the repair job [that function exists for.

Note the format string: lowercase 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">yyyy and dd. Attio's formatDate() tokens are case-sensitive date-fns style, and uppercase YYYY is the ISO week-year, which disagrees with the calendar year for a few days each January. That gotcha is covered in full in the [formatDate() article, and it bites here as surely as anywhere else.

Then the local version of a renewal countdown is:

dateDiff(
  date(formatDate(setTimezone(now(), "Australia/Sydney"), "yyyy-MM-dd")),
  {Renewal date},
  "days"
)

Keep the whole thing in one formula attribute. Splitting the local-today part into its own attribute and referencing it from a second one looks tidier and is not reliable — a formula that references another formula attribute returns wrong values with no error, a trap we have hit repeatedly across this series.

Three different timezone questions

"Show it in the right timezone" hides three questions that need three different answers.

Whose timezone does the business report in? One fixed zone, usually HQ. This is the answer for every quarter, month and day boundary in reporting, and it should be the same literal string in every formula that touches a boundary. Pick it once, write it down somewhere other than the formulas, and be consistent — a report bucketed in "America/New_York" and another bucketed in "America/Chicago" will disagree for an hour every day and nobody will work out why.

What time is it where this record is? This is per-record, and it needs a timezone value stored on the record to feed the second argument:

setTimezone({Next meeting}, {Timezone})

That shape is worth testing on one record before you build anything on it — every example in Attio's documentation passes a literal string, and whether the function accepts an attribute reference there is not something the docs state either way. If it does, you need a Timezone attribute populated with IANA names, which is a data problem rather than a formula one and generally means deriving it from country or state on import.

What time is it where the person looking at the screen is? A formula attribute cannot answer this, and it is worth being clear about why rather than hunting for a workaround. A formula attribute is computed per record, not per viewer. It has no idea who opened the page. A value that shows one thing to a rep in London and another to their manager in Denver is not something a stored field can do — that is a rendering concern, and it belongs to the client, not the formula.

Do not feed it a date

The signature accepts a date or a timestamp, and the date case deserves a warning.

A date has no time component and therefore no instant — 15 March is not a moment, it is a label covering a 24-hour span that starts at different instants in different places. Converting it to a timezone requires the function to pick an instant first, and whatever it picks, the result is a timestamp sitting at one edge of that span. Convert that to a zone behind the source and you can land on the previous calendar day.

Take setTimezone(date("2024-03-15"), "America/New_York") as the case to be suspicious of. Whatever it returns, the useful thing to notice is that you have asked a meaningless question: you had a clean, unambiguous, timezone-free label and you converted it into something that does have a timezone and can therefore disagree with itself.

Feed this function timestamps. If the value is a date, it is already correct in every timezone and it does not need converting.

How to test this without fooling yourself

This is the part that catches careful people.

If you test a timezone formula at 2pm on a Tuesday in London in January, London is UTC, and your formula returns the right answer whether or not it works. Your test passed for the wrong reason, exactly the way an ISO date string sorts correctly while still being text.

Test at a moment when the two zones disagree about something you can see:

  • Pick a record whose timestamp falls in the window where the local date and the UTC date differ — after 8pm in New York, before 11am in Sydney.
  • Check day() with and without the wrapper on that record. The two should differ by one. If they match, your test record was not in the window and you have learned nothing.
  • If daylight saving matters to you, check one record from January and one from July. The shift should differ by an hour. If it does not, something is being treated as a fixed offset.

Three records, two minutes, and it is the only way to know the formula works rather than assume it.

CRM use cases

  • Daily and weekly activity reports that match the working day. day(setTimezone({Created at}, "America/New_York")) — the fix for a "leads per day" chart whose boundaries fall in the evening.
  • Quarter and year attribution on closed deals. quarter(setTimezone({Closed on}, "America/New_York")) — so end-of-quarter deals land in the quarter the rep closed them in.
  • Business-hours classification of inbound. Wrap the timestamp once, compare the hour at both ends, and you can separate leads that arrived during staffed hours from those that sat overnight — which is the honest denominator for any response-time metric.
  • Local meeting hour for prep and routing. hour(setTimezone({Next meeting}, "America/Los_Angeles")) — usable in a formula in a way the rendered cell is not, for flagging calls booked outside a customer's working day.
  • color:var(--color-text-heading)]">Weekend detection that does not misfile Friday evening. Combined with the weekday reconstruction from the [mod() article, converting first stops a Friday 9pm New York lead being counted as Saturday.
  • A renewal or SLA countdown that flips at local midnight rather than partway through the afternoon.

Copy-paste formulas

Local hour of an event:

hour(setTimezone({Created at}, "America/New_York"))

Local calendar day of the month:

day(setTimezone({Created at}, "Australia/Sydney"))

Local quarter as a number:

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

Local quarter label for a report axis:

formatDate(setTimezone({Closed on}, "America/New_York"), "'Q'Q yyyy")

Arrived during business hours:

and(
  hour(setTimezone({Created at}, "America/Chicago")) >= 9,
  hour(setTimezone({Created at}, "America/Chicago")) < 18
)

Today, in your own timezone:

date(formatDate(setTimezone(now(), "Europe/London"), "yyyy-MM-dd"))

Days until renewal, counted from local today:

dateDiff(
  date(formatDate(setTimezone(now(), "Europe/London"), "yyyy-MM-dd")),
  {Renewal date},
  "days"
)

Day of week, timezone-corrected, where 0 is Monday and 5 and 6 are the weekend:

mod(
  dateDiff(
    date("2024-01-01"),
    date(formatDate(setTimezone({Created at}, "America/New_York"), "yyyy-MM-dd")),
    "days"
  ),
  7
)

Gotchas

  • The moment never moves. setTimezone() converts, it does not assign. If a system handed you a wall-clock time with no zone, this function cannot tell Attio where it came from.
  • It changes buckets, not durations. hour(), day(), month(), quarter(), year() and formatDate() all change. dateDiff(), dateAdd(), comparisons and min()/max() do not.
  • Innermost, always. Wrap the timestamp, then extract. And wrap it in *every* branch of a formula — a half-converted if() is the version that survives review.
  • Zone names, not offsets or abbreviations. "America/New_York", not "EST" and not -5. Abbreviations are ambiguous and offsets are wrong for half the year.
  • dateAdd() hour-shifting is not equivalent. It cannot follow daylight saving and it cannot express half-hour zones, and it recomputes historical records under today's assumption.
  • The return type is always a timestamp. Feed it a date and you get a timestamp with an edge-of-day instant, which can report as the previous day in a zone behind the source.
  • today() and now() are UTC-anchored. Build a local today from date(formatDate(setTimezone(now(), zone), "yyyy-MM-dd")) when the boundary itself matters.
  • Lowercase yyyy and dd. formatDate() uses case-sensitive date-fns tokens; uppercase YYYY is the ISO week-year.
  • Do not split it across formula attributes. A formula referencing another formula attribute returns wrong values with no error.
  • A date attribute has no timezone. If you find yourself converting a close date, the real fix is upstream: it should probably not be a timestamp.
  • Test in the window where the zones disagree. A test run while your zone happens to match UTC proves nothing.

Final thoughts

setTimezone() is a small function attached to a large idea, which is that "when did this happen" and "which day does it count towards" are two different questions and only the second one has a timezone.

Most CRM timezone bugs are not conversion errors. They are boundary errors — a day, a week or a quarter whose edges were drawn in UTC by default, on data belonging to people who do not live there. Nobody notices, because the numbers are plausible and the errors are small enough to be attributed to anything. They are also systematic, which is the part that should worry you: they lean the same direction every time, and evening activity is the activity most likely to be misfiled.

The fix is one wrapper in the right place, applied consistently. The harder part is deciding, once, which timezone your business reports in, and then holding to it everywhere — because two reports bucketed in two zones will disagree, and the disagreement will be blamed on the data.

If you are not sure which of your date attributes are timestamps and which are dates, that is the first thing to check, and it is one of the things a workspace audit turns up quickly — usually alongside the imported text dates that should have been either one.

Next: number() and text(), the conversion functions that turn a value into the type you actually wanted — and the only function in Attio's library that fails loudly.

Setting up reporting in Attio that has to hold up across regions? Our AI-native Attio sprint covers the data model, the attribute types and the formulas as one job.

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.