Skip to main content

SF-0044 · Scenario · Hard

Can we create a roll-up summary or mimic roll-up summary functionality on a lookup relationship?

✓ Verified by Vikas Singhal · Last reviewed 5/17/2026

Salesforce’s native Roll-Up Summary field only works on Master-Detail. On a Lookup, the option is greyed out. But the need is real: you often want to aggregate child data (count, sum, max) onto a parent that’s only linked by lookup — e.g., total Opportunity Amount per Account. There are four mainstream ways to mimic the feature, ranging from declarative no-code to full Apex.

Option 1 — Declarative Lookup Rollup Summaries (DLRS)

DLRS is a free open-source managed package by Salesforce Labs (originally by Andy Fawcett). It’s the most popular community solution and is widely deployed in production orgs.

How it works:

  • You install the package, then for each roll-up you create a Rollup Summary record in DLRS that specifies parent object, child object, lookup field, aggregate operation (COUNT, SUM, MAX, MIN, AVG, CONCAT, etc.) and target field on the parent.
  • DLRS generates Apex triggers, scheduled jobs, or both — managed by the package — that keep the rollup live.
  • Supports realtime mode (triggers fire on child change), scheduled mode (batch recalc), or programmatic mode (call from your own Apex).

Pros: declarative-feeling, well-documented, supports lookup, allows complex aggregates roll-up summary doesn’t (AVG, CONCAT, first/last). Cons: it’s still triggers under the hood — counts toward Apex trigger limits and code coverage.

Recommended for: 90% of cases where admins want roll-ups on lookups.

Option 2 — Apex trigger (custom code)

If you want to avoid managed packages or need very specific logic, write your own trigger:

trigger OpportunityRollUpToAccount on Opportunity (
    after insert, after update, after delete, after undelete
) {
    Set<Id> accountIds = new Set<Id>();

    if (Trigger.isInsert || Trigger.isUndelete) {
        for (Opportunity o : Trigger.new) if (o.AccountId != null) accountIds.add(o.AccountId);
    }
    if (Trigger.isUpdate) {
        for (Opportunity o : Trigger.new) {
            Opportunity oldO = Trigger.oldMap.get(o.Id);
            if (o.AccountId != null) accountIds.add(o.AccountId);
            if (oldO.AccountId != null && oldO.AccountId != o.AccountId) accountIds.add(oldO.AccountId);
        }
    }
    if (Trigger.isDelete) {
        for (Opportunity o : Trigger.old) if (o.AccountId != null) accountIds.add(o.AccountId);
    }

    if (accountIds.isEmpty()) return;

    Map<Id, AggregateResult> agg = new Map<Id, AggregateResult>();
    for (AggregateResult ar : [
        SELECT AccountId, SUM(Amount) total, COUNT(Id) cnt
        FROM Opportunity
        WHERE AccountId IN :accountIds
        GROUP BY AccountId
    ]) {
        agg.put((Id) ar.get('AccountId'), ar);
    }

    List<Account> toUpdate = new List<Account>();
    for (Id aId : accountIds) {
        AggregateResult ar = agg.get(aId);
        toUpdate.add(new Account(
            Id = aId,
            Total_Opp_Amount__c = ar != null ? (Decimal) ar.get('total') : 0,
            Opportunity_Count__c = ar != null ? (Integer) ar.get('cnt') : 0
        ));
    }
    update toUpdate;
}

Pros: full control, no dependencies. Cons: you own the code — bulk safety, exception handling, recursion guards, test coverage.

Option 3 — Record-triggered Flow with subflow / Get Records

For low-volume scenarios, a Record-Triggered Flow on the child object can recalculate the parent’s roll-up field:

  1. Trigger on child create / update / delete.
  2. Get Records: query siblings (other children of the same parent).
  3. Loop through siblings, compute sum/count in a variable.
  4. Update Records: set the parent’s roll-up field to the computed value.

Pros: 100% no-code. Cons: each child save runs the flow, which queries siblings and updates the parent — burns governor limits fast at scale. Don’t use this for objects with thousands of children per parent.

Option 4 — Scheduled Apex / Batch Apex

If real-time updates aren’t required, run a nightly Batch Apex or scheduled flow that recomputes the rollup from scratch:

public class NightlyRollupBatch implements Database.Batchable<SObject>, Schedulable {
    public Database.QueryLocator start(Database.BatchableContext bc) {
        return Database.getQueryLocator('SELECT Id FROM Account');
    }
    public void execute(Database.BatchableContext bc, List<Account> accs) {
        // recompute roll-up for each account in this batch
    }
    public void finish(Database.BatchableContext bc) {}
    public void execute(SchedulableContext sc) {
        Database.executeBatch(new NightlyRollupBatch(), 200);
    }
}

Pros: simple, doesn’t touch every transaction. Cons: not real-time; stale values until next run.

When each option is right

NeedBest option
Declarative, real-time, freeDLRS managed package
Real-time, custom logic, owned in-houseApex trigger
Low volume (under 100 children per parent), no-codeRecord-triggered Flow
Real-time not required, billions of rowsBatch Apex / scheduled flow

What native roll-up does that custom alternatives don’t

  • Atomic with parent save — Salesforce recalculates as part of the same transaction
  • Always consistent — no cron lag, no missed triggers
  • No code maintenance — admins don’t have to maintain it
  • Counts against zero governor limits for the child save

That’s why, when you can convert to Master-Detail and use a native roll-up, you should.

Limits to remember

Even with custom roll-ups, you’re bound by Salesforce governor limits:

  • Apex CPU time per transaction (10s sync, 60s async)
  • SOQL queries (100 per transaction)
  • DML rows (10,000 per transaction)

DLRS and good triggers bulkify and chunk — naive flows often do not. Test with realistic volumes before going live.

Verified against: DLRS documentation and Salesforce Help — Apex Triggers. Last reviewed 2026-05-17.