A client's inventory customization had a rule: deleting a transaction line should also release a reserved quantity elsewhere, and roll back an internal counter it had incremented on insert. The first implementation put that logic in RowDeleted. It mostly worked, until a user deleted a line, the system correctly decremented the counter, and then the user hit Cancel before saving - leaving the counter permanently wrong relative to a delete that, from the database's point of view, never actually happened. That's the whole story of RowDeleting vs RowDeleted in one bug: one of them fires on a delete that might still be undone, and the other doesn't run until it's committed, and picking wrong means your side effects and your data disagree.
RowDeleting: before removal, cancellable. RowDeleted: after removal, not cancellable, still not committed
Both events fire when a row is removed from the cache - via a user clicking a grid's delete button, or code calling cache.Delete(row) - but at genuinely different moments relative to that removal:
cache.Delete(row) called
→ RowDeleting (row still present in cache with status about to change;
e.Cancel = true here fully blocks the delete)
→ [row status becomes Deleted in the cache]
→ RowDeleted (row is marked Deleted in-memory; too late to cancel
the deletion itself, but nothing is in the database yet)
... user may still Cancel the entire edit session here - nothing is final ...
Save.Press()
→ RowPersisting (fires even for Deleted-status rows - last chance to
block the actual DELETE statement)
→ [SQL DELETE executes]
→ RowPersisted (row is now actually gone from the database)
The detail that catches people: both RowDeleting and RowDeleted fire purely in-memory, against the cache, well before Save. Neither one means the row is actually gone from the database. A user can delete a grid line, see RowDeleted fire and any code in it run, and then close the screen without saving - the deletion never happens in SQL, but any external side effect your RowDeleted handler triggered (an API call, a counter decrement, a notification) already happened and has no way to undo itself.
RowDeleting: validation and blocking, because it's the only one that can actually stop the delete
If a delete should be conditionally disallowed - a line can't be removed because it's already been partially shipped, a document can't be deleted because it's referenced elsewhere - that check belongs in RowDeleting, because it's the only one of the two events where setting e.Cancel = true genuinely prevents the row from being marked for deletion at all.
protected virtual void _(Events.RowDeleting<SOLine> e)
{
SOLine row = (SOLine)e.Row;
if (row == null) return;
if (row.ShippedQty > 0)
{
e.Cache.RaiseExceptionHandling<SOLine.lineNbr>(row, row.LineNbr,
new PXSetPropertyException("Cannot delete a line that has already been shipped."));
e.Cancel = true;
}
}
RowDeleted: cascading in-memory changes to other cached rows, not external effects
RowDeleted is the right place for adjusting other rows still in the same graph's cache in response to the deletion - recalculating a document total now that a line is gone, for instance, where the recalculation itself needs to reflect the deletion but doesn't commit anything external:
protected virtual void _(Events.RowDeleted<SOLine> e)
{
SOLine row = (SOLine)e.Row;
if (row == null || Base.Document.Current == null) return;
// Purely in-memory recalculation - safe even if the user cancels
// the whole edit afterward, because nothing external happened.
Base.Document.Current.LineCount = Base.Transactions.Select()
.RowCast<SOLine>().Count(l => l.LineNbr != row.LineNbr);
Base.Document.Cache.Update(Base.Document.Current);
}
Where the counter decrement actually belonged: RowPersisted, not RowDeleted
The fix for the inventory reservation bug was moving the external side effect - the reserved-quantity release, the counter decrement, anything that talks to something outside this graph's own cache - out of RowDeleted entirely and into RowPersisted, checking the row's operation for a delete specifically. RowPersisted only fires after the SQL DELETE has actually committed, which is the only point at which "this row is genuinely gone" is true rather than merely "marked gone in memory, pending user confirmation."
protected virtual void _(Events.RowPersisted<SOLine> e)
{
if (e.TranStatus != PXTranStatus.Completed) return; // only after real commit
if (e.Operation.Command() != PXDBOperation.Delete) return;
SOLine row = (SOLine)e.Row;
ReleaseReservedQuantity(row.InventoryID, row.OrderQty); // external side effect,
// now guaranteed real
}
Both delete events fire on in-memory state that a user can still walk away from without saving. Anything that can't be undone - an API call, a file deletion, a counter that lives outside this transaction, a notification - needs to wait until RowPersisted confirms the delete actually committed. This is the exact same discipline as RowPersisted vs RowPersisting for inserts and updates, just less obvious because "delete" sounds final even when it hasn't happened yet.
Wrapping up
RowDeleting is for validation that can still block the delete; RowDeleted is for adjusting other in-memory cache state that's safe to be wrong if the user backs out afterward; RowPersisted, checked for a delete operation, is the only one of the three that means the row is truly gone from the database. The bug I chased came from treating RowDeleted as if it meant "committed" when it only ever meant "the cache thinks so, for now" - the same category of mistake as the insert-side RowInserted/RowPersisted confusion, just easier to miss because delete feels more final than it is.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.