The mod() function in Attio: the remainder is the most useful number nobody uses
Every other math function in Attio answers the question "how much". sum() totals, avg() levels, round() tidies. mod() answers a different question entirely: what's left over.
That sounds like the least commercially interesting function in the library, and for a while I treated it that way. It isn't. mod() is the only function in Attio that turns a continuous number into a repeating, evenly sized cycle — and cycles are how you do stable round-robin assignment, how you detect off-list pricing, and how you build the weekday() function Attio never shipped.
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(), and day()/hour().
Table of contents
- What mod() does
- The one thing random() cannot give you
- mod() needs a number, and Attio will not convert one for you
- Building the weekday() function Attio does not have
- Remainders that mean something
- Composing with the rest of the library
- CRM use cases that earn their keep
- Copy-paste formulas
- Gotchas
- Final thoughts
What mod() does
mod() takes two numbers and returns the remainder after dividing the first by the second.
mod(10, 3) → 1
mod(9, 3) → 0
mod(7, 4) → 3The property that matters is the range. mod(anything, 4) can only ever return 0, 1, 2 or 3. Feed it a million different numbers and they all land in four buckets, and the buckets come out roughly the same size. That is the entire trick, and everything below is an application of it.
There are two useful readings of the result:
- As a bucket. You don't care what the remainder means, only that it's a stable small number you can group by. This is round-robin, territories, experiment cohorts.
- As a leftover. You care about the actual quantity that didn't divide evenly — the seats that don't fill a pack, the months that don't fill a year, the pounds above a round price.
Those are different jobs and the second one is the one people forget exists.
The one thing random() cannot give you
If you want to split accounts into four groups, the obvious reach is random(). Attio has it, and the pillar shows the usual pattern for it — round(random() * 100, 0).
Do not use it for assignment. A formula attribute is not a stored value that gets written once; it is a computed value that recalculates. Every recalculation re-rolls random(), so the account that was in cohort 2 this morning is in cohort 4 this afternoon, and your territory split, your A/B holdout and your audit sample are all quietly worthless. Nothing errors. The numbers just move.
mod() has the opposite property. Same input, same output, forever:
mod({Account number}, 4)That account is in bucket 2 today, next quarter, and after a re-import. You can build reporting on it, hand a bucket to a rep, and compare cohorts over time — none of which survives contact with random().
The honest framing: random() is for sampling, where you want a fresh draw and don't care about the same record twice. mod() is for assignment, where the answer has to hold still. If you find yourself wanting random() for anything a human will act on, you want mod().
One condition attaches. mod() is only as stable as its input. Bucket on {Deal value} and the cohort changes the moment someone renegotiates. Pick something immutable: an imported account number, a legacy CRM ID, an employee count that doesn't move much, anything that isn't part of the workflow you're measuring.
mod() needs a number, and Attio will not convert one for you
This is where most first attempts die.
mod() takes numeric inputs. Attio record IDs are not integers, and there is no toNumber(), number() or equivalent anywhere in the formula library — I've checked the full function list, and the conversions run one way only, into dates and timestamps, never into numbers. So the obvious "just mod the record ID" instinct has nowhere to go.
What that means practically: deterministic bucketing requires you to already have a number on the record. Usually you do and you've forgotten about it — an account number from the ERP, a sequential ID that came across in the migration, a Companies House or DUNS number, employee count, a customer number in the billing system. If you don't have one, adding a plain number attribute and populating it during import is a two-minute job that unlocks everything in this article. Do it at migration time; retrofitting a stable ID onto a live workspace is a much worse afternoon.
There is a fallback if you're stuck with text and only need rough buckets:
mod(length({Company name}), 3)length() returns a number, so this compiles and it is deterministic. It's also lopsided — most company names cluster in a narrow band of lengths, so your three buckets won't be close to equal — and it changes the day someone fixes a typo in the name. Use it for a quick eyeball split, never for anything with commission attached.
Building the weekday() function Attio does not have
The last article in this series established that Attio has no month(), year(), minute() or weekday() — day() and hour() are the only date-part extractors, and formatDate() fills the gap for month and year but returns text.
Weekday is the one that actually hurts. "Did this land on a weekend?" and "which day do our inbound leads arrive?" are ordinary questions, and there's no function for them. mod() is the way in.
Every seven days, the calendar repeats. So if you count days from a date you know was a Monday, the remainder after dividing by seven tells you the weekday:
mod(dateDiff(date("2000-01-03"), {Close date}, "days"), 7)3 January 2000 was a Monday. The result is 0 for Monday, 1 for Tuesday, and so on to 6 for Sunday. Check it against a date you know: 15 March 2024 is 8,838 days after that anchor, and 8,838 mod 7 is 4 — Friday, which is what the calendar says.
A weekend flag falls straight out:
mod(dateDiff(date("2000-01-03"), {Close date}, "days"), 7) >= 5And the readable version, if you want the name on the record:
if(mod(dateDiff(date("2000-01-03"), {Close date}, "days"), 7) == 0, "Mon",
if(mod(dateDiff(date("2000-01-03"), {Close date}, "days"), 7) == 1, "Tue",
if(mod(dateDiff(date("2000-01-03"), {Close date}, "days"), 7) == 2, "Wed",
if(mod(dateDiff(date("2000-01-03"), {Close date}, "days"), 7) == 3, "Thu",
if(mod(dateDiff(date("2000-01-03"), {Close date}, "days"), 7) == 4, "Fri",
if(mod(dateDiff(date("2000-01-03"), {Close date}, "days"), 7) == 5, "Sat", "Sun"))))))Two conditions on the anchor, and they are not optional.
It has to be a Monday at midnight. Any Monday works; 2000-01-03 is convenient because it's early and easy to remember. If you pick a Tuesday by mistake the whole scale rotates by one and everything is confidently wrong.
It has to be earlier than every date you'll feed it. dateDiff() returns the *absolute* difference — it cannot tell past from future. For any date before your anchor the count runs backwards and the weekday mapping mirrors, silently. An anchor in 2000 covers any CRM I've seen; if you're storing founding dates or historical records that predate it, move the anchor back to another Monday well before your earliest date.
This is the pattern worth internalising beyond weekdays: mod() plus a day count is how you reconstruct any repeating calendar Attio doesn't model. Fortnightly payroll cycles, four-week sprint numbers, a rotating on-call week — same shape, different divisor.
Remainders that mean something
The second reading of mod() — the leftover as a quantity you actually care about — gets much less attention and is often the faster win.
Seats that don't fill a pack. If you sell in bundles of ten, ceil({Users} / 10) tells you how many bundles to invoice. mod() tells you the part of the last bundle that's empty:
mod({Users}, 10)A 7 there means the customer is paying for three seats of headroom. A 1 means they are one seat away from being forced into another bundle — which is either a friction point to fix or an expansion conversation to have this month, depending on how you sell.
Contracts that don't align to a year. Finance likes annual boundaries; sales signs whatever closes.
mod({Contract length months}, 12)Zero means the term lands cleanly on an anniversary. Anything else is a renewal that will fall mid-year and drag a proration calculation behind it. A filter on that one column finds every awkward contract in the book.
Prices that aren't on the price list. If your list prices are multiples of 500, then any deal value that isn't is a discount, a custom quote, or a typo:
mod({Deal value}, 500) != 0I've run this on a workspace expecting a handful of exceptions and found a third of the pipeline off-list. That's not a formula finding, it's a pricing-governance finding, and it took one attribute to surface.
Composing with the rest of the library
mod() is a plumbing function — it earns its place inside other formulas, not on its own.
With 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">ceil() from the [rounding article, to describe a pack split completely:
ceil({Users} / 10) → bundles to invoice
mod({Users}, 10) → seats used in the last bundleWith if() to turn a bucket into something a human reads:
if(mod({Account number}, 4) == 0, "Team A",
if(mod({Account number}, 4) == 1, "Team B",
if(mod({Account number}, 4) == 2, "Team C", "Team D")))With abs() to keep the dividend positive, because sign conventions for negative remainders differ between implementations and you should not be finding out which one Attio uses on live data:
mod(abs({Variance}), 100)With ?? to survive blanks, since an empty attribute in either position takes the whole field down:
mod({Account number} ?? 0, 4)And with day() from the last article, for the pairing that describes a billing cycle end to end — which day of the month it bills on, and whether that day even exists in the month in question.
CRM use cases that earn their keep
- Territory and pod assignment.
mod({Account number}, 4)gives four stable, evenly sized groups. Reassignment means changing the divisor, not re-running a script. - Experiment cohorts and holdouts. Split accounts into test and control that stay split.
mod({Account number}, 10) == 0is a clean 10% holdout you can still identify in six months when you're measuring the result. - Weekend close detection. Deals closing on Saturdays are usually backdated data entry, not heroics. The weekday formula above finds them.
- Inbound-by-weekday coverage. Group leads by weekday number and the staffing gap shows up in one chart — the same argument as the hour-of-day analysis in the last article, at the other scale.
- Bundle headroom.
mod({Users}, 10)surfaces every account sitting just under a pack boundary, which is the cheapest expansion list you will ever build. - Off-list pricing audit.
mod({Deal value}, 500) != 0as a checkbox, filtered in a view, is a standing discount-governance report. - Non-anniversary renewals.
mod({Contract length months}, 12) != 0gives finance the list of terms that won't land where they expect. - Rotating duties. On-call, QA sampling, call reviews — anything that cycles through N people or N weeks is a
mod()away.
Copy-paste formulas
Adjust attribute names to match your workspace.
mod(10, 3)
mod({Account number}, 4)
mod({Account number}, 10) == 0
mod({Users}, 10)
mod({Contract length months}, 12)
mod({Contract length months}, 12) != 0
mod({Deal value}, 500) != 0
mod(dateDiff(date("2000-01-03"), {Close date}, "days"), 7)
mod(dateDiff(date("2000-01-03"), {Close date}, "days"), 7) >= 5
and(mod(dateDiff(date("2000-01-03"), {Close date}, "days"), 7) >= 5, {Deal value} > 50000)
mod({Account number} ?? 0, 4)
mod(abs({Forecast} - {Actual}), 1000)
mod(length({Company name}), 3)
if(mod({Account number}, 4) == 0, "Team A", if(mod({Account number}, 4) == 1, "Team B", if(mod({Account number}, 4) == 2, "Team C", "Team D")))Gotchas
A divisor of zero is undefined. If the divisor is an attribute rather than a literal, guard it — if({Divisor} == 0, 0, mod({Value}, {Divisor})). Hard-coded divisors are fine and are what you'll use 95% of the time.
Blanks propagate. An empty attribute on either side takes the field out. Default it with ?? before it reaches mod().
Text inputs won't work and can't be converted. There is no text-to-number function in the library. If your only stable identifier is text, you need a numeric attribute, not a cleverer formula.
Bucket stability is input stability. Modding a field that changes — deal value, stage-derived numbers, anything a rep edits — gives you buckets that reshuffle. It fails silently, which is worse than erroring.
Negative dividends are a coin flip. Different systems disagree about the sign of a negative remainder. Wrap the dividend in abs() and remove the question.
The weekday anchor must be a Monday, and it must be early. A wrong-weekday anchor rotates every answer by a constant. An anchor later than some of your dates mirrors those answers, because dateDiff() is absolute. Both failures produce plausible-looking output.
Even buckets need an even-ish input. mod() distributes evenly if the inputs are evenly spread. Sequential account numbers are. Employee counts, deal values and name lengths cluster hard, so the buckets come out lumpy — fine for a rough split, not fine for a controlled experiment.
Force the output type. A bucket left on Auto may be read as a number when you wanted a category, or vice versa. Set Number for arithmetic, Text for labels, Checkbox for the != 0 tests — otherwise sorting and filtering behave in ways you'll spend twenty minutes debugging.
The three-attribute nesting ceiling still applies. The weekday label formula above is long but it's one attribute, and nesting inside a single editor is unlimited. It's chaining formula *attributes* that caps out at three deep.
Final thoughts
mod() looks like the function you'd skip. What it actually gives you is the only source of deterministic structure in the formula library — the ability to say "this record belongs in group 2" and have that still be true next quarter. random() cannot do that, and everything people build with random() for assignment is broken in a way that doesn't announce itself.
The weekday recipe is the clearest demonstration of the underlying idea: a function Attio doesn't have, reconstructed out of a day count and a remainder. Any repeating cycle your business runs on — fortnights, sprints, rotations, quarters-that-aren't-calendar-quarters — is available the same way.
If your workspace has no stable numeric identifier on its records, that's the thing to fix first. Everything above depends on it, and it costs one attribute.
Working through a formula layer that's grown past what one person can hold in their head? Get a free Attio audit and we'll map it — or see how we build this properly from the start in an 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.
Two ways in. Pick the friction that fits.