Transactional Integrity in Apex: Mastering Partial Success, Savepoints, and Rollbacks

Share

In Salesforce, almost everything — DML, SOQL, triggers, and flows — runs inside a single transaction. That’s powerful because it ensures data consistency: if something fails and isn’t handled, the entire transaction rolls back automatically.

But in the real world, not everything needs to be all-or-nothing. Sometimes you want partial success (save what’s valid and report the rest). Other times, you need atomic control (make sure a group of operations either fully succeeds or not at all).

By mastering partial DML with Database.SaveResult and transaction control with Savepoint and Database.rollback, you can build automations that are both predictable and user-friendly.


Apex Transactions in a Nutshell

  • A transaction includes all DML operations, queries, triggers, and flows executed in a single context.

  • If an unhandled exception occurs, Salesforce automatically rolls back everything in that transaction.

You can, however, design for more nuanced outcomes:

  • Partial success: Use Database.insert(list, false) or Database.update(list, false) to process each record individually. Salesforce returns per-row success or failure results instead of throwing an exception for the whole operation.

  • Atomic control: Use Savepoint sp = Database.setSavepoint(); to mark a safe spot, and Database.rollback(sp); to undo everything that happened after that point — without affecting earlier successful work.


Partial Success with Database.*(..., allOrNone=false)

When you use the allOrNone=false flag, Salesforce attempts to process every record in the list — even if some fail.

You can then check the Database.SaveResult[] or Database.UpsertResult[] arrays to see which rows succeeded and which didn’t, along with the exact error messages.

This is ideal for:

  • Data imports

  • Bulk clean-up scripts

  • “Best-effort” data enrichment jobs

To make it even more user-friendly, you can aggregate errors into a log, a message summary, or even a Platform Event notification.


Savepoint and Rollback for Atomic Segments

Savepoints let you group operations into mini transactions within a larger one.

Here’s how it works:

  1. Create a savepoint before a critical section of your code.

  2. If anything fails in that block, perform a rollback to revert all operations that happened after the savepoint.

  3. Optionally, log the failure or convert it into a friendly, user-facing error message — and continue with other actions.

This pattern is perfect for operations like:

  • Creating a parent record and multiple children together (ensuring no orphan records).

  • Running multiple complex operations where one failure shouldn’t undo everything.


Mixing Both Patterns

A common hybrid approach looks like this:

  • Validate input data first.

  • Use a savepoint before creating a parent record (atomic segment).

  • Then perform partial DML for child records, capturing errors but keeping the valid ones.

You can also reverse the flow:
Do partial work first, and if a critical threshold of errors is reached, roll back the entire transaction to keep your data clean and consistent.


Practical Guardrails

Keep these rules in mind for healthy transactional design:

  • You can have a maximum of 5 savepoints per transaction — deep recursion or nested operations can quickly hit that limit. Keep savepoint scopes tight.

  • External side effects (emails, callouts, async operations) can’t be rolled back. Defer them to a Queueable or Platform Event until after the database state is final.

  • In triggers, gather data first, then perform a single bulk DML at the end. Use addError() carefully when you truly need all-or-nothing behavior.


Real-World Example with Code

Scenario

A user uploads a batch of Contacts linked to existing Accounts. The business logic requires:

  • Accounts to be processed atomically (either all succeed or none).

  • Contacts to be processed with partial success — save valid ones and report the rest.

  • If more than 20% of Contacts fail, roll back the entire batch for data quality control.


A) Entry Point (Service Layer)

public with sharing class ContactImportService {
    public class ImportReport {
        public Integer accountsProcessed = 0;
        public Integer contactsSuccess = 0;
        public Integer contactsFailed = 0;
        public List<String> errors = new List<String>();
    }

    public static ImportReport importBatch(
        List<Account> accountsToUpsert,
        List<Contact> contactsToUpsert
    ) {
        ImportReport report = new ImportReport();

        // 1) Atomic accounts block
        Savepoint sp = Database.setSavepoint();
        try {
            // Upsert accounts (atomic): fail fast if any account breaks
            upsert accountsToUpsert Account.External_Id__c; // throws on error → caught below
            report.accountsProcessed = accountsToUpsert.size();
        } catch (Exception ex) {
            Database.rollback(sp);
            report.errors.add('Account upsert failed, none were saved: ' + ex.getMessage());
            // Optionally rethrow or return report immediately
            return report;
        }

        // 2) Partial success contacts
        if (!contactsToUpsert.isEmpty()) {
            Database.UpsertResult[] results =
                Database.upsert(contactsToUpsert, Contact.External_Id__c, /* allOrNone */ false);

            Integer failures = 0;
            for (Integer i = 0; i < results.size(); i++) {
                Database.UpsertResult r = results[i];
                if (r.isSuccess()) {
                    report.contactsSuccess++;
                } else {
                    failures++;
                    for (Database.Error e : r.getErrors()) {
                        report.errors.add('Contact row ' + i + ' failed: ' + e.getStatusCode() + ' - ' + e.getMessage());
                    }
                }
            }
            report.contactsFailed = failures;

            // 3) Quality threshold → rollback the whole batch if too many contact errors
            Decimal failureRate = (Decimal.valueOf(failures) / Decimal.valueOf(Math.max(1, results.size()))) * 100;
            if (failureRate > 20) {
                Database.rollback(sp); // undoes the account changes too
                report.errors.add('Failure rate ' + failureRate.setScale(2) + '% exceeded 20% threshold. All changes rolled back.');
                // Counters reflect rolled back state for accounts; contacts are gone too
                report.accountsProcessed = 0;
                report.contactsSuccess = 0;
                report.contactsFailed = 0; // since we rolled back, nothing persisted
            }
        }

        return report;
    }
}

B) Using the Service (Controller, Batch, or Invocable)

List<Account> accounts = new List<Account>{
    new Account(External_Id__c='A-100', Name='Acme Ltd'),
    new Account(External_Id__c='A-200', Name='Globex LLC')
};

List<Contact> contacts = new List<Contact>{
    new Contact(External_Id__c='C-1', LastName='Doe', Email='john.doe@acme.com', AccountId=accounts[0].Id),
    new Contact(External_Id__c='C-2', LastName='(bad)', Email='not-an-email', AccountId=accounts[1].Id) // will fail validation
};

ContactImportService.ImportReport rep = ContactImportService.importBatch(accounts, contacts);

// Optionally surface rep.errors to UI, a Platform Event, or a Log__c object.

C) Variant: Atomic Parent + Partial Children (Trigger-Safe Design)

public with sharing class AtomicParentPartialChildren {
    public static void saveOrderWithLines(Order__c orderRec, List<OrderLine__c> lines) {
        Savepoint sp = Database.setSavepoint();
        try {
            // Atomic parent
            insert orderRec; // throws → rollback in catch
        } catch (Exception ex) {
            Database.rollback(sp);
            throw new AuraHandledException('Could not save the order: ' + ex.getMessage());
        }

        // Best-effort lines
        for (OrderLine__c l : lines) l.Order__c = orderRec.Id;
        Database.SaveResult[] sr = Database.insert(lines, false);

        // Report errors but keep parent + successful lines
        Integer failed = 0;
        for (Integer i=0; i<sr.size(); i++) {
            if (!sr[i].isSuccess()) {
                failed++;
                for (Database.Error e : sr[i].getErrors()) {
                    System.debug('Line ' + i + ' failed: ' + e.getMessage());
                }
            }
        }

        // Optional: tough stance—if any line is critical, roll back everything
        if (failed > 0 && isCritical(lines)) {
            Database.rollback(sp);
            throw new AuraHandledException('Order creation rolled back due to critical line issues.');
        }
    }

    private static Boolean isCritical(List<OrderLine__c> lines) {
        // domain-specific rule; return true if any required product missing, etc.
        return false;
    }
}

D) Aggregating and Surfacing Errors

public with sharing class ErrorUtils {
    public static String summarize(Database.SaveResult[] results) {
        List<String> msgs = new List<String>();
        for (Integer i=0; i<results.size(); i++) {
            if (!results[i].isSuccess()) {
                for (Database.Error e : results[i].getErrors()) {
                    msgs.add('Row ' + i + ': ' + e.getStatusCode() + ' - ' + e.getMessage());
                }
            }
        }
        return String.join(msgs, '\n');
    }
}

Trigger timing


Final Thoughts

Think carefully about which operations must be all-or-nothing and which can tolerate partial success.

  • Use savepoints to create atomic “islands” within a larger transaction.

  • Use Database.*(…, false) methods to capture and handle record-level errors without aborting the entire operation.

  • Keep your transaction scopes tight, limit savepoints, and push any side effects (emails, callouts, or async logic) out of the transaction.

When done right, your Apex logic becomes resilient, predictable, and user-friendly — saving what’s valid, clearly reporting what failed, and never leaving your data in an inconsistent state.

  • October 21, 2025