Acumatica · Customization

Acumatica Events Explained — RowSelected, RowInserted, and Friends

A clear, example-driven walk through every Acumatica graph event you actually use — RowSelected, RowInserting, RowUpdated, FieldDefaulting, FieldUpdated, RowPersisting — with the right one for the job.

John Kihiu12 min read

The question I get asked most often by developers new to Acumatica, almost word for word: "why isn't my event handler firing when I expect it to?" The answer is nearly always a misunderstanding of exactly when each cache event fires relative to the others, because the names sound similar but the semantics are genuinely different. This post is the explanation I give, with the firing order laid out explicitly instead of implied.

A row's life, event by event

Walk through what happens when a user adds a new line to a grid, edits a field, and saves. The events fire in this order, and getting this order wrong is the source of most "my default isn't applying" or "my validation runs at the wrong time" bugs:

SEQUENCE
User clicks "Add Row"
  → RowInserting     (row not yet in cache; can cancel the insert)
  → RowInserted      (row now in cache with defaults applied; too late to cancel cleanly)
  → RowSelected       (fires immediately after insert, and again on every subsequent
                        refresh/paint of this row)

User edits a field
  → FieldDefaulting   (only if the field is being defaulted, not user-typed)
  → FieldVerifying    (validates the NEW value before it's committed to the row)
  → FieldUpdated      (value is now committed; react to the change here)
  → RowSelected       (fires again — the whole row re-evaluates)

User clicks Save
  → RowPersisting     (last chance to validate or block the save, per row,
                        inside the transaction)
  → [SQL executes]
  → RowPersisted      (row is now committed; safe for side effects that must
                        only happen after a successful save)

The two confusions I see constantly: developers reach for RowSelected to do something that belongs in RowInserted (one-time setup on row creation) because RowSelected is the one they learned first, and developers put save-blocking validation in RowPersisted — which is too late, the row is already committed.

RowSelected vs RowInserted: the distinction that matters most

RowInserted fires exactly once, at the moment a new row is created — a genuinely one-time event, the right place for logic like "set the initial status to Draft" or "copy a value from the parent record into this new child row." RowSelected fires constantly: on that same initial insert, yes, but also on every grid refresh, every field change anywhere on the row (because the framework re-evaluates the whole row's UI state after any field changes), and every time the user navigates back to a previously-loaded row. If you put "set the initial status" logic in RowSelected without a guard, you will forcibly reset the status back to Draft every time the user so much as tabs through a field — a real bug I've fixed on client instances more than once.

C#
// Correct: one-time initialization on row creation
protected virtual void _(Events.RowInserted<SOLine> e)
{
    if (e.Row == null) return;
    e.Row.UsrLineStatus = "N"; // New — set once, at creation
}

// Correct: UI state that should re-evaluate on every paint
protected virtual void _(Events.RowSelected<SOLine> e)
{
    if (e.Row == null) return;
    // Cheap, idempotent — safe to run every time
    PXUIFieldAttribute.SetEnabled<SOLine.usrOverrideReason>(cache: Base.Transactions.Cache,
        row: e.Row, enabled: e.Row.UsrLineStatus == "H");
}
RowSelected fires on rows that aren't fully loaded yet, too

During a screen's initial data bind, RowSelected can fire on rows in an intermediate state. Always null-check e.Row first, and avoid assumptions about sibling rows or aggregate totals being fully computed yet — if you need "all rows loaded" semantics, that belongs in graph-level logic after the view's Select completes, not inside a per-row RowSelected handler.

FieldVerifying, FieldUpdated, and RowPersisting — three different gates

FieldVerifying is your chance to reject a value before it lands on the row — throw a PXSetPropertyException here and the UI shows a red error immediately, without letting the invalid value commit. FieldUpdated fires after the value is already accepted, and is for cascading changes — recalculating a dependent field, defaulting a related value. RowPersisting is the last gate before SQL executes and is where cross-field, whole-row validation belongs, because by that point every field on the row has its final value and you can validate combinations that no single field's events could see in isolation.

C#
// Cross-field validation belongs in RowPersisting, not FieldVerifying —
// you need BOTH fields' final values, which FieldVerifying on either
// field alone cannot guarantee.
protected virtual void _(Events.RowPersisting<SOLine> e)
{
    if (e.Row == null) return;
    if (e.Row.UsrLineStatus == "H" && string.IsNullOrEmpty(e.Row.UsrOverrideReason))
    {
        e.Cache.RaiseExceptionHandling<SOLine.usrOverrideReason>(
            e.Row, e.Row.UsrOverrideReason,
            new PXSetPropertyException("Override reason is required when the line is on hold"));
        e.Cancel = true;
    }
}

RowPersisted: only for things that must follow a real commit

RowPersisted fires after the transaction succeeds and is the correct — and only correct — place for side effects that must not happen if the save rolls back: sending a notification email, calling an external API, writing to an audit table outside the transaction. Putting an external call in RowPersisting instead means it fires even if a later row in the same save fails validation and the whole transaction rolls back, leaving you with a sent email for a save that never actually happened.

Wrapping up

The event names describe intent, and the firing order is not negotiable: Inserting/Inserted for one-time row creation, FieldVerifying/FieldUpdated for single-field validation and cascades, RowSelected for cheap UI state re-evaluated constantly, RowPersisting for final cross-field validation inside the transaction, and RowPersisted for side effects that must only happen after a real commit. Every "my event isn't firing right" bug I've debugged for other developers has resolved to picking the wrong one of these five, not a framework defect.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.