[object Object]

Rollup columns compute aggregates over related rows — sum of opportunity revenue per account, count of activities per case. They feel free. They are not. Once you string rollups across more than one relationship hop, the assumptions break and the displayed value lies about real data. Most teams discover this when a sales report disagrees with a finance report by 12%.

What rollups actually do

A rollup column computes asynchronously. Two triggers:

  • Mass recalc on a system schedule (default: every 12 hours per column).
  • On-demand recalc via the column’s recalculate button or CalculateRollupFieldRequest.

The aggregate is computed against the related rows in scope at recalc time. The result is stored back on the parent row. Reads after recalc are fast — they hit the stored value. Reads between recalcs are stale.

Anti-pattern 1: trusting the displayed value for real-time decisions

The most common mistake. A rollup of “open opportunities revenue” on account is used in a workflow that triggers when revenue exceeds a threshold. The workflow runs on the parent row. But the rollup may be hours stale. An opportunity gets created and pushes the real total over the threshold; the workflow does not fire until the next recalc, at which point the data may have moved on.

Fix: do not key business logic off rollup columns. For automation, compute the aggregate at decision time via a plugin query or FetchXML aggregate.

<fetch aggregate="true">
  <entity name="opportunity">
    <attribute name="estimatedvalue" alias="total" aggregate="sum"/>
    <filter>
      <condition attribute="parentaccountid" operator="eq" value="{accountid}"/>
      <condition attribute="statecode" operator="eq" value="0"/>
    </filter>
  </entity>
</fetch>

The aggregate is real-time. The rollup column is for human display.

Anti-pattern 2: rollups over rollups

A rollup column counts as a stored attribute. You can write a rollup on parent A that aggregates a rollup on child B. The platform allows it; the math is wrong. The child rollup is itself stale. The parent rollup aggregates stale values. The two staleness windows compound. Past two hops, the displayed value diverges from reality by single-digit percentages on average, much more under load.

Fix: collapse multi-hop rollups into a single rollup that directly aggregates the leaf rows. If the platform’s relationship topology does not support a direct rollup, use a calculated column powered by a server-side query (a plugin populating a stored attribute on demand).

Anti-pattern 3: rollups with filters that reference current time

A rollup with a filter statecode = 0 (open) is safe. A rollup with a filter createdon > ago(7d) is not. The “ago(7d)” boundary moves with time, so the rollup needs to recalculate just because the clock advanced — not because data changed. The recalc still only runs on schedule, so the displayed value is wrong relative to “now” most of the time.

Fix: avoid time-window filters in rollup definitions. Compute the time-windowed view via a server-side aggregate or a measure in Customer Insights / Power BI.

Anti-pattern 4: rollups across N:N relationships

Standard rollups do not traverse many-to-many relationships. People build “intermediate” tables to fake it: account → account_to_team → team_member. The intermediate join doubles or halves your counts depending on the cardinality. Rollups will happily aggregate and return a wrong number.

Fix: do not rollup through intermediate tables. Use a custom plugin that walks the relationship explicitly and writes the result to a stored column.

Anti-pattern 5: business rules consuming rollups

Business rules execute synchronously, on form load and on field change. A rule that uses a rollup column value executes against whatever value is currently stored — which may be hours stale. The user sees the rule fire with stale logic and assumes the form is correct.

Fix: business rules should consume only synchronously-current attributes. If you need rule logic over an aggregate, materialize the aggregate via a plugin on the form-load event or a JavaScript fetch.

The replacement pattern

For aggregates that must be both displayed and reasoned over, use a plugin-maintained stored column. The plugin fires on Create/Update/Delete of the child rows and writes the aggregate to the parent. Synchronous on the child operation, which means writes cost slightly more, but the parent attribute is always correct.

public class MaintainOpportunityTotal : IPlugin {
  public void Execute(IServiceProvider sp) {
    var ctx = (IPluginExecutionContext)sp.GetService(typeof(IPluginExecutionContext));
    var svc = ((IOrganizationServiceFactory)sp.GetService(
      typeof(IOrganizationServiceFactory))).CreateOrganizationService(null);

    var target = (Entity)ctx.InputParameters["Target"];
    var accountId = ((EntityReference)target["parentaccountid"]).Id;

    var aggregate = svc.RetrieveMultiple(new FetchExpression($@"
      <fetch aggregate='true'>
        <entity name='opportunity'>
          <attribute name='estimatedvalue' alias='total' aggregate='sum'/>
          <filter>
            <condition attribute='parentaccountid' operator='eq' value='{accountId}'/>
            <condition attribute='statecode' operator='eq' value='0'/>
          </filter>
        </entity>
      </fetch>")).Entities[0];

    svc.Update(new Entity("account", accountId) {
      ["cr_opentotal"] = aggregate.GetAttributeValue<AliasedValue>("total")?.Value
    });
  }
}

Register on PostOperation of opportunity Create/Update/Delete and on transitions of statecode/estimatedvalue.

When rollups are still the right answer

  • The aggregate is for human display only, not automation.
  • Staleness up to 12 hours is acceptable.
  • The relationship topology is simple (single hop, no N:N).
  • The filter does not reference current time.
  • The volume is moderate (under a few hundred thousand child rows per parent).

If all five hold, rollups are fine. They are also free to maintain.

See also

Calculated, rollup, and formula columns compared — the broader landscape of computed columns, with tradeoffs that differ from rollups. We also covered business rules at scale for the rule-side of the same problem.

Pixel notes

Display rollup-driven values with a small “last updated” timestamp in the field’s helper text. The timestamp is the parent row’s modifiedon filtered to rollup recalc events, surfaced via JavaScript. Users learn to mistrust the number when it is stale, which is the correct calibration.

Forge notes

Plugin-maintained aggregates have a transactional gotcha. If the parent update fails (concurrency conflict, write privilege), the child operation rolls back. Register the aggregate-maintenance plugin async unless atomic consistency is required. Async write-back tolerates parent failure without losing the child.

Key takeaways

  • Rollups are eventually-correct, not real-time.
  • Do not key automation off rollup values; query at decision time.
  • Avoid multi-hop rollups; collapse to a single direct aggregate.
  • Avoid time-window filters in rollup definitions.
  • Replace business-critical aggregates with plugin-maintained stored columns.
[object Object]
Share