The answer, briefly: if the relationship can be master-detail, convert it and use a real roll-up summary field — the platform then maintains the number on every event, including restores, which no automation you write covers fully. If it can’t, your options are a record-triggered flow, scheduled recalculation, or Apex, and whichever you pick must handle insert, update, delete, undelete, and reparenting. A roll-up flow firing only on create and update is not “mostly working.” It is wrong, and it drifts further every week.
Why the field is locked to master-detail
Roll-up summary fields live on the master side of a master-detail relationship. The Metadata API’s definition of summaryForeignKey states the constraint plainly: it “represents the master-detail field on the child that defines the relationship between the parent and the child.” There is no lookup variant.
The reason is architectural, not arbitrary. A master-detail child is owned by its parent — it cascades on delete, inherits sharing, cannot be orphaned. That ownership is what lets the platform recompute the aggregate at every mutation point. A lookup child can be reparented, left blank, deleted, and restored from the Recycle Bin with the parent never involved. Salesforce declines to guarantee an aggregate over data with those properties, and once you try to maintain one yourself you find out why.
The five events, and what each option actually covers
| Approach | Insert | Update | Delete | Undelete | Reparent |
|---|---|---|---|---|---|
| Master-detail + roll-up summary field | Yes | Yes | Yes | Yes | Yes |
| Record-triggered flow on the child | Yes | Yes | Before-delete only | No trigger exists | Yes, if you code it |
| Apex trigger on the child | Yes | Yes | Yes | Partially | Yes, if you code it |
| Scheduled flow recalculation | Eventually | Eventually | Eventually | Eventually | Eventually |
Insert and update are the easy 60%
An after-save record-triggered flow on the child, firing on create or update, recalculates the parent. This is the part everybody builds, and it works. Note that “update” includes a change to the summarised value — an Amount going from 500 to 900 must move the parent by 400, not add 900, so entry conditions keyed only on creation silently miss edits.
Delete is where most flow roll-ups are simply broken
Flow does support a delete trigger, and be precise about what it gives you: the flow runs before the record is deleted. There is no after-delete flow. So the pattern is to read the parent Id off the record that is about to disappear, recalculate excluding it, and write the parent.
Delete gets skipped because it is invisible in normal testing — nobody deletes records in a demo. Then a data load with a bad match key gets rolled back by deleting 4,000 rows, and every parent total in the org is permanently too high with no way to tell which ones. If your roll-up flow has no delete trigger, it is broken. That is not a hedge.
Undelete cannot be handled in flow at all
This is the honest limit of the declarative approach. Flow’s record trigger options cover created, updated, and deleted. There is no undelete option, so a child restored from the Recycle Bin fires nothing and your total stays wrong.
Apex is better here but not complete. The after undelete event fires only for custom objects and a defined list of standard objects: Account, Asset, Campaign, Case, Contact, Contract, Event, Lead, Opportunity, Product, Solution, Task among them. More importantly, the Apex Developer Guide says undelete triggers “only run on top-level objects”: delete an Account and its Opportunities cascade, then restore the Account and the Opportunities come back without firing their own after undelete. Even a correct Apex roll-up drifts when a grandparent is restored, which is the argument for the scheduled reconciliation below. It is not belt-and-braces. It is the only thing that catches this class of drift.
Reparenting needs two parents updated in one transaction
When a child’s lookup moves from Account A to Account B, one child update must decrement A and increment B. Compare {!$Record__Prior.Account__c} to the current value and recalculate both when they differ. Miss it and the number is wrong on both sides at once. Master-detail has the mirror-image gotcha: reparenting is off unless an admin selects Allow reparenting on the relationship.
Recalculate, don’t increment. The drift is arithmetic
Two ways to write the update: adjust the parent by a delta, or recompute the parent’s total from scratch. Take the recompute, every time.
A delta is a running total that never re-derives itself, so every missed event is permanent and two automations on the same record double-count. A recompute is self-healing: get it wrong once, and the next child change on that parent fixes the number. That property is worth more than the SOQL you save.
The catch is that Flow cannot do an aggregate query. Get Records returns rows, so recomputing a parent with 5,000 children means pulling 5,000 records into a collection and looping. Heap and CPU that scale with child count, inside a transaction that already has other work in it. Fine for small child counts. Past a few hundred, it isn’t.
Apex does it in one row:
public with sharing class OpenCaseRollup {
@InvocableMethod(label='Recalculate Open Case Count')
public static void recalculate(List<Id> accountIds) {
Map<Id, Account> toUpdate = new Map<Id, Account>();
for (Id accId : accountIds) {
toUpdate.put(accId, new Account(Id = accId, Open_Case_Count__c = 0));
}
for (AggregateResult ar : [
SELECT AccountId aid, COUNT(Id) total
FROM Case
WHERE AccountId IN :accountIds AND IsClosed = false
GROUP BY AccountId
]) {
Id accId = (Id) ar.get('aid');
toUpdate.get(accId).Open_Case_Count__c = (Integer) ar.get('total');
}
update toUpdate.values();
}
}
Seeding every requested Id with zero before the aggregate runs is the part people leave out. GROUP BY returns no row for a parent whose children all went away, so without the seed the last child’s deletion leaves the old total sitting there. The exact bug you set out to fix.
Scheduled recalculation: cheap, and wrong all day
A schedule-triggered flow that runs nightly over parent records is the simplest thing that eventually converges. Its costs are worth stating plainly.
The number is stale between runs. If the field drives a validation rule, an assignment decision, or anything a user reads and acts on, “correct as of 2am” is not correct. Reporting tolerates it. Process logic does not.
It also has volume limits. Schedule-triggered flows are capped at 250,000 interviews per 24 hours, or the number of user licenses in your org multiplied by 200, whichever is greater. An org with 200,000 Accounts recalculating all of them nightly spends most of that allowance on records that did not change. Filter the schedule’s entry conditions to parents with recent child activity, or run the reconciliation as Batch Apex, where you get chunked transactions and a query locator.
For a lookup roll-up that has to be right: event-driven automation for immediacy, plus a nightly reconciliation for the events no trigger can see. Both. The reconciliation is the undelete story.
The case where converting the relationship is the wrong answer
Converting a lookup to master-detail sounds like the clean fix, and sometimes it is. Check what else it changes first.
Master-detail makes the relationship required, so every existing child needs the field populated. Blanks block the conversion. The child then inherits the parent’s sharing, so anyone who can see the parent can see the child. If the child holds data with tighter visibility, that is a security regression dressed up as a data model change. Deleting the parent deletes the children. And if the child genuinely needs to exist without a parent, master-detail is the wrong model regardless of what it would do for your roll-up.
Going the other way has its own trap: to change a master-detail field back to a lookup you must first delete any roll-up summary fields on the master that reference the detail object.
Frequently asked
Can a formula field aggregate child records?
No. Cross-object formulas traverse from child to parent, one record at a time. They cannot look down the relationship or aggregate. This is the single most common wrong turn on this topic.
Can a before-save flow do the roll-up and save the DML?
No. A before-save flow can only set fields on the triggering record, and the roll-up field is on a different record. You need an after-save flow, which means the parent update is a separate DML statement.
Why is my count off by exactly one?
Usually the delete path recalculating while the record still exists. A delete-triggered flow runs before the deletion, so a Get Records over children still returns the record on its way out. Exclude it by Id.
Does the parent update count against my DML limit?
Yes. It runs in the child’s transaction and consumes the shared budget of 150 DML statements and 10,000 DML rows. Update parents once as a collection, never inside a loop.
Is there a supported tool for this?
Salesforce publishes a developer article on a declarative rollup summary tool for lookup relationships, and open-source options exist. None is part of the platform, so treat one as a dependency you own. Including its delete and undelete behaviour, which is the problem you started with.
Reference: CustomField, Metadata API Developer Guide; Triggers and Recovered Records and Execution Governors and Limits, Apex Developer Guide. Relationship conversion rules per “Considerations for Relationships” and “Roll-Up Summary Field”, Salesforce Help.
