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:
- Trigger on child create / update / delete.
- Get Records: query siblings (other children of the same parent).
- Loop through siblings, compute sum/count in a variable.
- 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
| Need | Best option |
|---|---|
| Declarative, real-time, free | DLRS managed package |
| Real-time, custom logic, owned in-house | Apex trigger |
| Low volume (under 100 children per parent), no-code | Record-triggered Flow |
| Real-time not required, billions of rows | Batch 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.