The round(), ceil(), and floor() functions in Attio: board-deck numbers, seat bundles, and honest tenure
Formulas produce exact numbers, and exact numbers are usually the wrong deliverable. A weighted pipeline of $19,847.3333 can't go in a board deck; 4.2 seat bundles can't go on an invoice; 5.8 months as a customer is not six months, whatever rounding says. round(), ceil(), and floor() are the three ways to trim a number — and choosing between them is really choosing *which direction it's safe to be wrong in*.
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(), and avg()/median(). This one covers the three rounding functions together, because the interesting part is the choice between them.
Table of contents
- What round(), ceil(), and floor() do
- round(): numbers for humans
- ceil(): numbers for billing
- floor(): numbers for progress
- Picking the direction
- Composing with the rest of the library
- CRM use cases that earn their keep
- Copy-paste formulas
- Gotchas
- Final thoughts
What round(), ceil(), and floor() do
One rounds to the nearest, one always up, one always down:
round(value, num_digits)
ceil(value)
floor(value)round(1234.5678, 2) returns 1234.57. ceil(7.2) returns 8. floor(9.8) returns 9. Only round() takes a digits argument — ceil() and floor() always land on a whole number, which fits their jobs: things you count in units.
Force the output type to Number (or Currency for money) rather than leaving it on Auto.
round(): numbers for humans
round() is presentation. The math behind a weighted pipeline is exact and should stay exact — but the number a human reads shouldn't be:
round({Deal value} * 0.4, 0)$19,847.3333 becomes $19,847, and the board deck stops looking like a debugger. Two digits for currency that keeps cents, zero for anything a person scans in a view. The rule: round() never goes into further math if you can help it — it's the last step before eyes, not an input to the next formula. Round once, at the end.
ceil(): numbers for billing
ceil() is the commercial one. Anything sold in units — seats in packs, licenses, storage blocks, support hours — has the same shape: the customer's need is fractional, the thing you sell isn't, and the fraction always costs a whole unit.
ceil({Users} / 10)42 users, packs of 10: ceil(4.2) = 5 bundles. round() here would say 4 — and undercharge every customer whose need lands in the bottom half of a pack. That's the reason ceil() exists as a separate function: when the direction of the error has a price, "nearest" is not neutral.
floor(): numbers for progress
floor() is the honest one. Elapsed time, completed units, anything earned — the partial one doesn't count until it's finished:
floor(dateDiff({Signup date}, today(), "months"))A customer 5.8 months in has completed 5 months, not 6. Tenure segments, anniversary triggers, "eligible for renewal pricing after 12 full months" — all of these break subtly with round(), because for half of each period it grants the milestone early. floor() is also the "full quarters as a customer" and "complete years since founding" function, for the same reason.
Picking the direction
The three functions are one decision table:
| The number is for… | Wrong-direction cost | Use |
|---|---|---|
| Reading — decks, views, reports | None; it's display | round(x, 0) |
| Charging — seats, packs, blocks | Rounding down undercharges | ceil(x) |
| Progress — tenure, completed units | Rounding up overstates | floor(x) |
If trimming the number changes what someone pays or qualifies for, it's not formatting — pick the direction deliberately. If it doesn't, it's round().
Composing with the rest of the library
Rounding is a finishing move, so it wraps the others. Clean up an avg() rollup for display:
round(avg({Deal value}) ?? 0, 0)Floor a dateDiff() for tenure, then route on it with if():
if(floor(dateDiff({Signup date}, today(), "months")) >= 12, "Anniversary pricing", "Standard")The ?? guard goes *inside* the rounding — blank-proof the input, then trim the result. And the pillar's random() sampler is the same pattern: round(random() * 100, 0) turns a raw random into a clean 0–100 score for picking audit samples.
CRM use cases that earn their keep
- Board-deck weighted pipeline —
round({Deal value} * 0.4, 0)— important because stakeholders read the number, and $19,847.3333 reads as noise, not precision. - Seat bundles to quote —
ceil({Users} / 10)— important because you never sell 4.2 bundles, and rounding down is a discount nobody approved. - Full months as a customer —
floor(dateDiff({Signup date}, today(), "months"))— important because tenure milestones granted early — even by half a month — compound across every account. - Anniversary-pricing flag —
if(floor(dateDiff({Signup date}, today(), "months")) >= 12, "Anniversary pricing", "Standard")— important because eligibility rules need completed months, and this one can't fire early. - Clean average deal size —
round(avg({Deal value}) ?? 0, 0)— important because the rollup's long tail of decimals hides the actual magnitude at a glance. - Support blocks consumed —
ceil({Hours used} / 5)— important because a 5-hour block that's 20 minutes used is a consumed block, and billing math has to agree with the contract.
Copy-paste formulas
Swap in your attribute names and these work as-is:
Weighted pipeline, deck-ready (Currency output):
round({Deal value} * 0.4, 0)Seat bundles needed, packs of 10 (Number output):
ceil({Users} / 10)Full months as a customer (Number output):
floor(dateDiff({Signup date}, today(), "months"))Anniversary-pricing flag (Text output):
if(floor(dateDiff({Signup date}, today(), "months")) >= 12, "Anniversary pricing", "Standard")Average deal size, clean and blank-safe (Currency output):
round(avg({Deal value}) ?? 0, 0)Gotchas
- Only round() takes digits.
ceil()andfloor()always go to the nearest integer. To ceil to one decimal, scale:ceil({x} * 10) / 10. - Round last. Rounding an input and then multiplying compounds the error. Keep intermediate formulas exact; trim in the final, human-facing attribute.
- round() at the midpoint. Halfway values have to break one way; if a threshold sits exactly on a .5 boundary, test a record on it rather than assuming.
- color:var(--color-text-heading)]">Blank in, blank out. All three pass null through. Guard the input with [?? before rounding, not after.
- Daily refresh for date math. Anything built on
today()recalculates around midnight UTC, so a tenure floor flips the day the month completes — fine for milestones, not for minute-level countdowns. - Nesting limit. Three formula attributes deep. Wrap the rounding in the same formula as the math rather than adding a rounding-only attribute on top of a stack.
Final thoughts
round(), ceil(), and floor() look like formatting; two of them are policy. Trim for the reader with round(), charge in whole units with ceil(), grant milestones on completed periods with floor() — and any time the direction of the error has a cost, make sure the function you picked errs the way the business does.
For the rest of the library — every history, logic, math, date, and text function with CRM use cases — see the complete guide to Attio formula attributes.
And if you'd rather have your billing math, tenure segments, and deck-ready rollups designed and shipped for you, that's 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.
Book a free discovery callReady when you are.
Two ways in. Pick the friction that fits.