Sooner or later every Salesforce org hits the same wall: you want to show a total, a count, or a "most recent" date on a parent record, but the relationship to the children is a lookup, not a master-detail. Native roll-up summary fields refuse to help. Here's why that happens and how to roll up across any lookup relationship — declaratively, in bulk, and safely.
Why native roll-up summary fields aren't enough
Salesforce roll-up summary fields are wonderful, but they carry a hard restriction: they only exist on the master side of a master-detail relationship. That design works when the child truly cannot exist without the parent, but it breaks down in the most common real-world modeling situations:
- The child already has a required master-detail to another object (you only get two per object anyway).
- You need the child to be reparentable, or to exist independently — which means a lookup.
- You're rolling up standard objects like Contacts to Accounts, where you don't control the relationship type.
The moment the relationship is a lookup, the declarative path disappears and you're left choosing between a record-triggered Flow, hand-written Apex, or a paid tool. Each of these works, but each has a catch you should understand before you commit.
The options, and their trade-offs
Record-triggered Flows are approachable and admin-friendly, but rollups in Flow get verbose fast (you're looping, filtering, and aggregating by hand), and they can be hard to keep bulk-safe and DML-efficient across inserts, updates, deletes, and reparenting. Hand-written Apex triggers give you full control but tend to be copy-pasted per object, so the same aggregation bug ships five times. Managed packages solve it well but add a dependency and, often, a cost.
A better middle ground is a small, metadata-driven rollup engine: one well-tested Apex service that reads a configuration record and maintains the aggregate for you. You define the rollup once as data, not code.
What a correct lookup rollup has to handle
Whichever route you pick, a rollup that survives a real data load must correctly handle all four events:
- Insert — add the child's contribution to its parent.
- Update — if the rolled-up value changed (e.g. Amount), recalculate the parent.
- Delete / undelete — remove or restore the contribution.
- Reparenting — when the lookup changes, recalculate both the old and new parent.
It also has to be bulk-safe: group children by parent, recalculate each affected parent exactly once, and update all parents in a single DML statement so a 10,000-row data load doesn't blow the governor limits.
A declarative rollup, defined as metadata
The approach we use in the MF Rollup Engine accelerator is to describe each rollup in a Custom Metadata Type record. Nothing about the object or field is hard-coded — the same Apex runs every rollup in the org:
Rollup: Account_Open_Cases
Child SObject: Case
Parent Lookup: AccountId
Operation: COUNT
Filter (optional): IsClosed = false
Target Field: Open_Case_Count__c (on Account)
Want a sum of open opportunity amounts instead? Add another record:
Rollup: Account_Pipeline_Amount
Child SObject: Opportunity
Parent Lookup: AccountId
Operation: SUM
Field to aggregate: Amount
Filter: StageName != 'Closed Lost'
Target Field: Pipeline_Amount__c (on Account)
Supported operations cover the ones you actually reach for: COUNT, SUM, MIN, MAX, and AVG, each with an optional filter so you can roll up "open" or "won" subsets rather than everything.
Keeping it in sync: trigger + batch
Two pieces keep the number honest. A trigger on the child object calls the rollup service on every DML event so parents stay current in real time. A batch job recalculates a rollup from scratch — indispensable when you first deploy a rollup onto years of existing data, or after a bulk migration that bypassed triggers.
Conceptually the runtime does this per rollup:
1. Collect the parent Ids touched by this DML (old + new for reparenting).
2. Aggregate the children in one SOQL query, grouped by the parent lookup,
applying the optional filter.
3. Map each parent Id to its new aggregate value.
4. Update all affected parents in a single DML call.
Because the aggregation happens in the database (a single grouped query) rather than in an Apex loop, it scales to large child volumes without burning CPU time.
Tip: run rollups without sharing so totals are accurate even when the running user can't see every child record — a count of "open cases" should be correct regardless of who's looking at the account.
Backfilling existing data
Adding a rollup to a mature org is where teams get burned: the trigger only fires on new activity, so historical parents show zero until something changes. Always pair a new rollup with a one-time backfill:
// Recalculate a single rollup across every existing parent
Database.executeBatch(new MF_RollupBatch('Account_Open_Cases'), 200);
Run it once after deployment and your existing accounts, opportunities, or custom parents immediately reflect the correct totals.
When to reach for this pattern
Use a declarative lookup rollup whenever you need an aggregate on a parent and the relationship isn't (or can't be) master-detail: open case counts on Accounts, pipeline amounts on Accounts or custom "Deal" objects, latest activity dates, counts of related compliance items, and so on. It keeps the logic in one tested place and turns "add a rollup" into a five-minute metadata change instead of a development task.
Frequently asked questions
Can you roll up a field across a lookup relationship in Salesforce?
Not with native roll-up summary fields, which require master-detail. You roll up across a lookup with Apex, a Flow, or a configurable engine that maintains the aggregate via a trigger plus a batch backfill.
What's the difference between a master-detail and a lookup rollup?
Master-detail supports declarative roll-up summary fields automatically. Lookups don't, so aggregates like COUNT, SUM, MIN, MAX, and AVG have to be maintained by automation whenever children change or are reparented.
Are Apex rollups bulk-safe?
They have to be. A correct rollup groups children by parent, recalculates each affected parent once, and updates parents in a single DML statement so it stays within governor limits during large data loads.