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

The random() function in Attio: a number that has not been rolled yet

Written by

Published 24 min readTested in live Attio workspaces
Contents18 sections
  1. What the random() function does
  2. Three functions take no arguments, and only two get a warning
  3. The one row in the docs that cannot be finished
  4. Randomness is not the problem, impermanence is
  5. A dice roll commits, random() never does
  6. The four things people actually want
  7. The 10 percent audit you can never finish
  8. Attio's own example is biased, and so is yours
  9. Arbitrary but stable, the pattern that actually works
  10. Where the created at trick breaks
  11. Stable round robin and stable cohorts
  12. Freezing a genuinely random number
  13. The one honest use, random() as a recalculation probe
  14. random() against today() and now()
  15. CRM use cases
  16. Copy-paste formulas
  17. Gotchas
  18. Final thoughts

Every other function in Attio's formula library has an answer you can look up. mod(10, 3) is 1. month(date("2024-03-15")) is 3. eomonth(date("2024-02-10")) is 2024-02-29. Feed the same inputs in tomorrow and you get the same output, which is why a formula attribute can be trusted at all: it is not storing a number, it is re-deriving one, and re-derivation lands in the same place every time.

random() is the exception, and it is the only one.

It takes no inputs, so there is nothing for it to be a function *of*. There is no answer to look up, no way to check that the number on the record is the right number, and no way to get the same number twice. A random() formula attribute does not hold a value. It holds a roll that has not happened yet, and it happens again, differently, every time the formula is evaluated.

That is not a quirk to work around. It is the whole character of the function, and it decides every question worth asking about it.

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(), number()/text(), and year()/quarter()/month().

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 the random() function does

The entire signature:

random()

Attio's documentation describes it in eleven words: it returns a random number between 0 and 1, and takes no arguments. That is the complete specification.

Two things follow immediately. First, there is no range argument. random(1, 100) is not valid syntax, and neither is a minimum-and-maximum form — if you want a number in a range you build it yourself by multiplying. Second, there is no seed. Some languages let you fix a seed so that "random" becomes reproducible; Attio does not expose one, so there is no such thing as the same random sequence twice.

Because a decimal between 0 and 1 is useless as a field on its own, random() is always wrapped. Attio's own documented example is:

round(random() * 100, 0)

Multiply to get the range you want, then round or floor to get a whole number. We will come back to that example, because it is subtly the wrong way round.

Three functions take no arguments, and only two get a warning

Here is the observation that organises everything else. Search Attio's function library for the phrase "Takes no arguments" and it appears exactly three times, on exactly three functions: now(), today(), and random().

Those three are the entire set of functions in Attio that do not derive their output from their input. Every other function in the library — every math function, every text function, every date extractor, every history function — is *pure*: same inputs, same output, forever. These three are not. Their value depends on something outside the formula.

And here is the part that matters. For two of the three, Attio's docs add an explicit second sentence. now() and today() are each documented as: *"Using this function adds a daily recalculation around midnight UTC."*

random() has no such sentence. Its row says what it returns and that it takes no arguments, and stops.

That absence is not a documentation oversight to be annoyed about. It is the most informative thing on the page. Attio tells you exactly when a now() formula will change, because you need to know — a countdown field that silently refreshes at midnight UTC is a thing you must plan around. It tells you nothing about when a random() formula will change, and the honest reading is that its refresh behaviour is undocumented. Which means: whatever you build on it, you are building on timing you were never given.

The one row in the docs that cannot be finished

There is a second tell, and it is almost funny.

Attio's math function table has seventeen rows, and every row has three columns: the function, a description, and an example. The example column always shows an expression and then its result. avg([1.5, 2.3, 4.7]) = 2.8333. ceil(7.2) = 8. mod(10, 3) = 1. power(2, 8) = 256. Seventeen rows, sixteen results.

The random() row shows round(random() * 100, 0) and then nothing.

There is no result, because there is no result to write. A documentation table that exists to show you what each function returns physically cannot complete that row. Notice also that the example does not even show a bare random() — the docs never once show you what random() alone evaluates to, because any number they printed would be a lie the moment it was printed.

Sixteen functions can be documented by example. The seventeenth can only be described. That is the difference between a value and a process, and random() is the only process in the library.

Randomness is not the problem, impermanence is

It is tempting to conclude that randomness is unsuited to a CRM. That is not quite right, and getting it right is what makes the rest of this useful.

Randomness is fine. CRMs need it. You need a random sample of records to audit, a random split for an experiment, a random assignment so that the same rep does not always get the best leads. Arbitrary is a legitimate and often necessary property of a business process.

What a CRM cannot tolerate is *impermanence*. A field is something two people can look at and agree on. It is something you can look at twice. It is something a view can filter on so that the same records are in the list tomorrow. The moment a field's value depends on when you looked at it, every one of those properties fails, and the field stops being data.

So the requirement is not "arbitrary" and it is not "random". It is arbitrary but stable — a number that has no meaning, chosen by nothing in particular, that nevertheless never changes for that record.

random() delivers arbitrary and unstable. That is the one combination out of the four that has no use, and it is the combination every wrong random() formula is reaching for.

The rule, and it applies well beyond this function: anything a human will act on twice has to be derivable twice.

A dice roll commits, random() never does

The reason random() feels safe is that it borrows the intuition of a physical dice roll, and the intuition is wrong in a specific way.

A dice roll is genuinely unpredictable, but it is also *committed*. You roll, the die lands, the number is now a fact about the world. Everyone at the table sees the same four. You can write it down. You can argue about it later.

random() is not a rolled die. It is a die still in the air. The number on a random() field is not "the number we got" — it is "the number we would get if the formula ran right now". Two people loading the record after a recalculation are not looking at one roll; they are looking at two.

Which is why it is more dangerous than a dice roll rather than equivalent to one. A dice roll can be unfair. A random() field can be *inconsistent*, and inconsistency in a CRM does not announce itself. Nobody gets an error. The number looks exactly as plausible as it did before. Somebody just quietly acts on a different number than the person before them did.

The four things people actually want

In practice, random() gets reached for in four situations. All four need stability, and all four break.

A sample to audit. "Give me a random 10% of records to check for data quality." You need to work through a fixed list until it is done. A resampling filter has no "done".

An experiment cohort. "Split accounts into A and B so we can test two nurture sequences." An experiment requires each record to stay in its arm for the whole measurement window. If membership changes, there is no experiment — there is just noise with a hypothesis attached.

A round-robin assignment. "Distribute inbound leads across three reps." Assignment must persist, or nobody owns anything. A lead that belongs to Alex when Alex opens it and Sam when Sam opens it belongs to neither.

A tie-break shuffle. "When lead scores tie, vary the order so the same records are not always at the bottom of the queue." This is the only one of the four where instability is arguably the *point* — you want the order to rotate. But it rotates while you are working the list, which means you lose your place, see records twice, and miss others entirely. Desirable in theory, unusable in practice.

Notice that three of the four are not really asking for randomness at all. They are asking for *fairness*, and fairness is achieved by an arbitrary rule applied consistently — not by an arbitrary rule reapplied constantly.

The 10 percent audit you can never finish

Worth making the sample case concrete, because it is the most common and the failure is the most instructive.

You want a tenth of your records for a data-quality review. So you create a formula attribute:

random()

and a view filtered to {Audit sample} < 0.1. Open it: about a tenth of your records, which is exactly what you asked for. It looks like it works.

On Monday, four hundred records are in the view. You get through a hundred and twenty. On Tuesday you open the view and there are four hundred and six records, and most of them are not the ones you saw yesterday. The hundred and twenty you already cleaned are largely gone from the list — not because you fixed them, but because they rerolled above 0.1. Records you have never seen are in. Records you cleaned will come back next week and you will clean them again.

The audit has no end state. You cannot report progress, because "120 of 400" was measuring a denominator that no longer exists. And critically, you cannot tell from the inside that anything is wrong: the view keeps showing roughly 10% of records, which is precisely what a working sample looks like.

This is the same shape of bug as the 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">month() collision from the [year(), quarter() and month() article — the output stays plausible while the meaning quietly leaves. Those are the expensive ones. A formula that errors gets fixed on day one.

Attio's own example is biased, and so is yours

Before we get to the fix, there is a mathematical problem with the documented example that is worth fixing everywhere it has been copied.

Attio's example for random() is:

round(random() * 100, 0)

That looks like "a random whole number from 0 to 100", and it nearly is. But random() produces a value in the interval from 0 up to (but not reaching) 1, so random() * 100 lands anywhere from 0 up to just under 100. Now round to the nearest whole number and count how wide each landing zone is:

  • 0 is produced by anything below 0.5 — a zone half a unit wide.
  • 1 through 99 are each produced by a full unit-wide zone.
  • 100 is produced by anything from 99.5 upwards — again half a unit wide.

So the example yields 101 possible outcomes, and the two at the ends are each exactly half as likely as the ninety-nine in the middle. For a display value that is harmless. For anything you are using to split records into groups, it means your first and last group are half the size of the others and your split is quietly wrong.

The fix is floor() rather than round(), and it is also simpler to reason about:

floor(random() * 100)

That gives 0 through 99, uniformly, one hundred outcomes of equal width. For 1 through 100:

floor(random() * 100) + 1

The general rule, and it is worth internalising for any bucketing work at all: color:var(--color-text-heading)]">floor() makes buckets, round() makes labels. floor(random() * n) gives exactly n equally likely values from 0 to n - 1. round() gives you n + 1 values with two half-weight ends, every time. The same distinction is why [round(), ceil() and floor() are three functions and not one.

One honest caveat: the docs say "between 0 and 1" without stating whether either endpoint is included. The near-universal convention is 0 included, 1 excluded, which is what makes floor(random() * 100) top out at 99. Do not build anything whose correctness depends on that boundary, and if you need a hard guarantee, wrap the result in mod() so an unexpected 1 cannot escape the range.

Arbitrary but stable, the pattern that actually works

Here is the thing to actually build. You want a number that is meaningless, evenly spread, and permanent. The way to get it is to stop asking for randomness and start harvesting arbitrariness from something the record already has.

Almost every record in Attio has a Created at timestamp. It has three properties that matter:

  1. It never changes. It is set once, at creation, and it is not something a user edits.
  2. It is unique enough. Records created by humans arrive at arbitrary moments.
  3. Its low-order digits are meaningless. Nobody chose the second within the minute that a record was created. That is free entropy sitting on every row.

Turn the timestamp into a number by measuring it in seconds from a fixed point in the past, then take a remainder:

mod(dateDiff(timestamp("2020-01-01T00:00:00Z"), {Created at}, "seconds"), 10)

Every record now carries a stable digit from 0 to 9, spread evenly, that means nothing and never changes. Force the output type to Number rather than leaving it on Auto.

Your fixed 10% sample is one comparison away:

mod(dateDiff(timestamp("2020-01-01T00:00:00Z"), {Created at}, "seconds"), 10) == 0

Filter a view to that checkbox and the membership is settled. Four hundred records on Monday, the same four hundred on Tuesday, minus the ones you fixed if your filter also excludes clean rows. Progress is measurable because the denominator is real. And if you want a different 10%, change the == 0 to == 7 and you get a completely different, equally stable tenth.

Want a 1% slice instead? mod(..., 100) == 0. A 50/50 split? mod(..., 2). The divisor is the number of buckets and the comparison picks which one.

13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">dateDiff() returning the absolute difference — a property that is usually an irritation, as covered in the [dateDiff() article — is actually harmless here, because a remainder does not care about sign. Still, pick an epoch earlier than your oldest record if you want the underlying number to also increase with time.

Where the created at trick breaks

This deserves a warning, because there is one situation where the entropy is not there and the bucket collapses.

Bulk imports. If ten thousand records were created by a CSV import or an API migration, a very large number of them share a Created at value down to the second. Every one of those gets the same bucket. Instead of a 10% sample you get either all of them or none of them, and a "50/50 split" can come out 95/5.

Check before you trust it. Group your records by the formula and look at the bucket sizes — if they are not roughly equal, you have found a cohort of records that arrived together. For imported data, use a different stable number instead: an external account number, an invoice number, or any incrementing identifier that came across in the import. The mod() article covers that shape in more detail.

Two related traps:

Do not build this on a formula attribute that points at another formula attribute. The tempting move is one formula called "Bucket" holding the mod(), then other formulas referencing {Bucket}. Referencing a formula attribute from another formula attribute is unreliable in Attio: it returns wrong values with no error, and can even return a value on rows where the referenced field is blank. Inline the whole expression every time, however ugly the repetition looks. That is a rule for the entire library, not just this article.

Do not use a field a human can edit. A bucket derived from {Deal value} moves when someone updates the deal value, and you are back to impermanence with extra steps.

Stable round robin and stable cohorts

With the stable digit in hand, the three legitimate use cases fall out.

Three-way round robin. Inline the expression in each branch:

if(mod(dateDiff(timestamp("2020-01-01T00:00:00Z"), {Created at}, "seconds"), 3) == 0, "Alex",
  if(mod(dateDiff(timestamp("2020-01-01T00:00:00Z"), {Created at}, "seconds"), 3) == 1, "Sam", "Jordan"))

Each record keeps its rep permanently. Note this is a *display* of the intended owner, not the owner field itself — a formula attribute cannot write to a person attribute, so use it to drive a workflow or as a visible cross-check on assignment, and see if() for the nesting pattern.

A/B cohort label.

if(mod(dateDiff(timestamp("2020-01-01T00:00:00Z"), {Created at}, "seconds"), 2) == 0, "A", "B")

Each record stays in its arm for as long as the experiment runs, which is the minimum requirement for the experiment to mean anything.

Stable percentile for staged rollouts.

mod(dateDiff(timestamp("2020-01-01T00:00:00Z"), {Created at}, "seconds"), 100)

Now "roll the new sequence out to 5%" is a filter on < 5, and widening to 20% later *includes* the original 5% rather than replacing it. That monotonic property is exactly what a real feature rollout needs and exactly what random() cannot give you — with random(), widening the threshold resamples the whole population.

Freezing a genuinely random number

Suppose you genuinely want true randomness, not a stable hash — a real lottery draw, a truly unpredictable sample that no one could have anticipated from the data.

Then the answer is that it cannot live in a formula attribute at all, and the reason is definitional: a formula attribute is re-derived by design. You cannot ask a thing whose purpose is recomputation to remember something.

The pattern is to move the value out of the formula layer and into the data layer. Have an automation write a number into a plain number attribute once, at record creation, and never again. Once it is a stored value rather than a derived one it is committed — a rolled die instead of one in the air — and everything downstream can rely on it.

Two things to verify when you build it, rather than assume: that the automation really does fire exactly once per record and cannot re-run over existing rows, and that records created before you built it get backfilled, because a half-populated random field is worse than none. Test both on a handful of records first.

The general principle is worth keeping: if a value must not change, it has to be stored, not computed.

The one honest use, random() as a recalculation probe

After all of that, random() does have one genuine job, and it is a good one. It is the only instrument in Attio for observing something you otherwise cannot see: when formulas actually recalculate.

Recalculation timing matters for a lot of the library. It decides when a now() countdown ticks over, when a rollup catches up after a linked record changes, and — as anyone who has hit the formula-referencing-a-formula problem knows — it is entangled with some genuinely confusing behaviour. But you cannot watch it happen with a normal formula, because a correct formula returns the same value before and after recalculating. The refresh is invisible precisely because it is correct.

random() makes it visible. It is the only expression where a recalculation and a no-op look different.

Create a throwaway formula attribute holding exactly random(), note the value on a test record, and then poke one thing at a time: reload the page without changing anything, edit an unrelated attribute on the record, change a linked record, wait past midnight UTC. Each time, check whether the number moved. What you learn is your workspace's actual recalculation behaviour, which is worth more than any single formula, and you will be reasoning from observation rather than guesswork the next time a rollup looks stale.

Then delete the probe. A random number left sitting in a workspace with a plausible name will eventually be trusted by somebody, and by then nobody will remember it was an experiment.

random() against today() and now()

The three impure functions, side by side, because the differences are the useful part:

today() / now()random()
Depends onthe clocknothing
Same for every viewer at a given momentYesNo
Can you check the value is correctYes, look at a clockNo, there is nothing to compare it to
Refresh documentedYes, daily around midnight UTCNo
Safe to filter a view onYesNo
Safe as a field humans act onYesNo

13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">today() and now() are impure in a disciplined way. They change, but they change for everyone at once, in one direction, on a published schedule, and you can always verify the value against a clock. That is why a renewal countdown built on [today() and now() is a legitimate CRM field.

random() shares none of those properties. It is not that it changes — it is that it changes unpredictably, per evaluation, unverifiably, and on no announced schedule.

CRM use cases

Honestly: there are no good ones for random() as a stored field. What follows is what to build instead, plus the single case where the function itself earns its place.

  • A fixed sample to audit — mod(dateDiff(timestamp("2020-01-01T00:00:00Z"), {Created at}, "seconds"), 10) == 0 — important because a data-quality review needs a list that can be finished, and a resampling filter has no end.
  • A stable experiment cohort — if(mod(dateDiff(timestamp("2020-01-01T00:00:00Z"), {Created at}, "seconds"), 2) == 0, "A", "B") — important because a record that changes arms mid-test destroys the result while still producing a number.
  • A stable percentile for staged rollouts — mod(dateDiff(timestamp("2020-01-01T00:00:00Z"), {Created at}, "seconds"), 100) — important because widening from 5% to 20% should add people, not reshuffle them.
  • Round-robin distribution — mod(..., 3) with an if() wrapper — important because fairness comes from applying an arbitrary rule consistently, not from re-rolling it.
  • Bucketing imported records — mod({Account number}, 4) — important because bulk-imported records share a Created at to the second, so the timestamp trick collapses exactly where you would need it most.
  • A recalculation probe — a throwaway attribute holding random() — important because it is the only way to see when Attio recalculates formulas, and that is worth knowing for every other formula in the workspace.

Copy-paste formulas

Swap in your attribute names and epoch. These work as-is.

A uniform random whole number from 1 to 100, unbiased, Number output:

floor(random() * 100) + 1

A stable pseudo-random digit from 0 to 9, Number output:

mod(dateDiff(timestamp("2020-01-01T00:00:00Z"), {Created at}, "seconds"), 10)

A fixed 10% sample flag, Checkbox output:

mod(dateDiff(timestamp("2020-01-01T00:00:00Z"), {Created at}, "seconds"), 10) == 0

A stable 50/50 experiment cohort, Text output:

if(mod(dateDiff(timestamp("2020-01-01T00:00:00Z"), {Created at}, "seconds"), 2) == 0, "A", "B")

A stable percentile from 0 to 99 for staged rollouts, Number output:

mod(dateDiff(timestamp("2020-01-01T00:00:00Z"), {Created at}, "seconds"), 100)

A stable three-way round-robin label, Text output:

if(mod(dateDiff(timestamp("2020-01-01T00:00:00Z"), {Created at}, "seconds"), 3) == 0, "Alex",
  if(mod(dateDiff(timestamp("2020-01-01T00:00:00Z"), {Created at}, "seconds"), 3) == 1, "Sam", "Jordan"))

A bucket for bulk-imported records, where Created at has no entropy, Number output:

mod({Account number}, 4)

Gotchas

  • It takes no arguments. random(1, 100) is not valid. Multiply and floor to build a range.
  • There is no seed. No way to reproduce a sequence, so there is no "the same random each time" option to reach for.
  • round() biases your buckets. round(random() * 100, 0) produces 101 outcomes with the two ends at half weight. Use floor(random() * 100) for uniform buckets.
  • The endpoints are unstated. The docs say "between 0 and 1" without saying whether either end is included. Do not build anything that turns on that boundary.
  • Never filter a view on it. The membership of that view is not a fact about your records; it is a fact about when you opened it.
  • Never let another formula read it. Formula-to-formula references are already unreliable in Attio; pointing one at a value that also changes on its own makes the result impossible to debug.
  • Its refresh schedule is undocumented. now() and today() come with an explicit note about daily recalculation around midnight UTC. random() comes with nothing. Observe it before you depend on it.
  • Force the output type. Number for a bucket, Checkbox for a sample flag, Text for a cohort label. Auto will make choices you did not intend.
  • A stable bucket needs a stable source. Derive it from Created at or an external identifier, never from a field a person edits.

Final thoughts

random() is the one function in Attio's library that cannot be documented by example, and Attio's own table proves it: sixteen math functions get a result, and the seventeenth gets an expression and a blank space. That blank is the function's entire nature. There is no value to print, because there is no value — only a roll that happens again each time somebody looks.

Which makes it the one function that can never be a CRM field, no matter how carefully it is wrapped. Not because randomness is wrong in a CRM, but because a field is a thing two people can agree on, and this one cannot survive being read twice.

The thing worth taking away is what almost everyone actually wanted when they typed random(): not randomness, but *arbitrariness that holds still*. And that you can build, out of entropy already sitting on every record, with mod() over the seconds in a Created at that will never change again. A number that means nothing and never moves — which turns out to be the useful half of random all along.

Next: phoneNumber() — a type conversion that will refuse your data unless it carries a country code, and what that reveals about how Attio really stores a phone number.

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.

And if you would rather have your sampling, cohorting and assignment logic designed so it holds still, that is 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.