Salesforce

Exception Handling in Apex: try, catch, and finally

In Apex, exception handling uses try/catch/finally to intercept runtime errors before they roll back your entire transaction and expose raw errors to users. Wrap risky code in try, catch specific types like DmlException, QueryException, or NullPointerException in ordered catch blocks, and use finally for guaranteed cleanup. Custom exceptions extend Exception and must end in "Exception". LimitException cannot be caught — guard with Limits.* methods instead. Always log async exceptions explicitly; the platform silently swallows unhandled errors in @future and Queueable contexts.

How Apex Handles Errors Without try/catch

Before adding any error handling, understand the default behaviour. When an unhandled exception escapes a transaction, the entire transaction rolls back — including all successful DML that ran before the exception. The caller (Visualforce, LWC, a Flow, an integration) receives a generic platform error with little context.

// No exception handling — dangerous in production
public class OrderService {
    public static void placeOrder(Id accountId, List<Product2> products) {
        Order newOrder = new Order(AccountId = accountId, Status = 'Draft',
                                   EffectiveDate = Date.today());
        insert newOrder;  // succeeds

        // If this query throws, the insert above is rolled back too
        List<PricebookEntry> entries = [
            SELECT Id, UnitPrice FROM PricebookEntry
            WHERE Product2Id IN :products AND Pricebook2.IsStandard = true
        ];

        // Downstream logic that might throw a NullPointerException
        processEntries(entries, newOrder.Id);
    }
}

One uncaught NullPointerException in processEntries and the Order insert never persisted. The caller has no idea which step failed or why.

The try / catch Block

Wrap the code that can fail inside try. Place recovery or logging logic in one or more catch blocks. Each catch declares the exception type it handles.

public class OrderService {

    public static String placeOrder(Id accountId) {
        try {
            Order newOrder = new Order(
                AccountId    = accountId,
                Status       = 'Draft',
                EffectiveDate = Date.today()
            );
            insert newOrder;
            return newOrder.Id;

        } catch (DmlException e) {
            // DML-specific information is available on DmlException
            System.debug('DML failed on field: ' + e.getDmlFieldNames(0));
            System.debug('DML message: '         + e.getDmlMessage(0));
            return null;
        }
    }
}

The catch block only executes when an exception of the declared type (or a subtype) is thrown inside the try. If no exception is thrown, the catch block is skipped entirely.

The Exception Hierarchy

Every Apex exception extends Exception, the base class. The platform ships with dozens of typed exceptions. The most important ones you will encounter:

  • DmlException — fired by any failed DML operation (insert, update, delete, upsert)
  • QueryException — fired when a SOQL query returns zero rows but the code assigns to a single SObject, or when a list assignment fails
  • NullPointerException — fired when dereferencing a null reference
  • LimitException — fired when a governor limit is breached; cannot be caught
  • SObjectException — fired when accessing a field not in the SELECT list
  • TypeException — fired on invalid type casts
  • MathException — fired on division by zero
  • EmailException — fired by Messaging failures
  • CalloutException — fired by failed HTTP callouts
  • JSONException — fired by malformed JSON parsing

Because all of these extend Exception, catching Exception catches everything catchable. That is a code smell when used without care — you lose specificity — but it has a legitimate place as a final safety net.

Catching Multiple Exception Types

A single try block can have multiple catch clauses. Apex evaluates them top to bottom and executes the first match. Order from most specific to least specific.

public class ContactImportService {

    public static void importContact(Map<String, Object> payload) {
        try {
            String email = (String) payload.get('email');
            String phone = (String) payload.get('phone');

            Contact c = new Contact(
                LastName = (String) payload.get('lastName'),
                Email    = email,
                Phone    = phone
            );

            insert c;
            System.debug('Imported contact: ' + c.Id);

        } catch (DmlException e) {
            for (Integer i = 0; i < e.getNumDml(); i++) {
                System.debug(LoggingLevel.ERROR,
                    'Row ' + i + ' failed: ' + e.getDmlMessage(i) +
                    ' | Status code: ' + e.getDmlStatusCode(i));
            }

        } catch (NullPointerException e) {
            System.debug(LoggingLevel.ERROR,
                'Required field missing in payload: ' + e.getMessage());

        } catch (Exception e) {
            // Safety net for TypeException, JSONException, etc.
            System.debug(LoggingLevel.ERROR,
                'Unexpected error [' + e.getTypeName() + ']: ' + e.getMessage());
        }
    }
}

e.getTypeName() returns the fully qualified exception class name (e.g. System.JSONException). e.getStackTraceString() returns the call stack — invaluable when logging to a custom object or external monitoring system.

Useful Methods on the Exception Base Class

Every exception object exposes these methods inherited from Exception:

  • getMessage() — the human-readable message
  • getTypeName() — the exception class name as a String
  • getStackTraceString() — full stack trace as a String
  • getCause() — the wrapped inner exception, or null
  • initCause(Exception cause) — wraps an inner exception before rethrowing

And the methods unique to DmlException:

  • getNumDml() — number of rows that failed
  • getDmlMessage(index) — error message for a specific row
  • getDmlStatusCode(index) — Salesforce error status code string (e.g. FIELD_CUSTOM_VALIDATION_EXCEPTION)
  • getDmlFieldNames(index) — list of field API names involved in the failure
  • getDmlId(index) — record Id of the failing row (if available)

The finally Block

Code inside finally runs regardless of whether the try succeeded or a catch executed. It runs even if there is a return statement inside try. Use it for cleanup that must always happen: releasing resources, resetting state, or writing an audit log.

public class ExternalSyncService {

    private static Boolean syncInProgress = false;

    public static void syncRecords(List<Account> accounts) {
        syncInProgress = true;

        try {
            HttpRequest req = new HttpRequest();
            req.setEndpoint('callout:ExternalSystem/sync');
            req.setMethod('POST');
            req.setHeader('Content-Type', 'application/json');
            req.setBody(JSON.serialize(accounts));

            HttpResponse res = new Http().send(req);

            if (res.getStatusCode() != 200) {
                throw new CalloutException(
                    'Sync failed with HTTP ' + res.getStatusCode()
                    + ': ' + res.getBody()
                );
            }

            System.debug('Sync succeeded for ' + accounts.size() + ' records.');

        } catch (CalloutException e) {
            System.debug(LoggingLevel.ERROR, 'Callout error: ' + e.getMessage());
            throw e;  // rethrow so the caller knows the sync did not complete

        } finally {
            // Runs even when we rethrow above
            syncInProgress = false;
            System.debug('Sync lock released.');
        }
    }
}

The finally block still executes before the rethrown exception propagates to the caller. This is the correct pattern when you need local cleanup but want the caller to handle the failure.

Rethrowing and Wrapping Exceptions

Sometimes the right action is to catch, enrich, and rethrow. Use initCause to preserve the original exception while throwing a higher-level domain exception.

public class AccountRepository {

    public static Account getById(Id accountId) {
        try {
            return [SELECT Id, Name, AnnualRevenue FROM Account WHERE Id = :accountId];

        } catch (QueryException e) {
            AccountNotFoundException domainEx =
                new AccountNotFoundException('Account not found: ' + accountId);
            domainEx.initCause(e);
            throw domainEx;
        }
    }

    public class AccountNotFoundException extends Exception {}
}

The caller catches AccountNotFoundException — a meaningful business-layer error — while getCause() still lets a logging layer inspect the original QueryException.

Custom Exceptions

Declare a custom exception by extending Exception. The class name must end with "Exception". Declare it top-level or as an inner class of the service that throws it.

public class OrderValidationException extends Exception {

    private String orderNumber;

    public OrderValidationException(String message, String orderNumber) {
        this(message);
        this.orderNumber = orderNumber;
    }

    public String getOrderNumber() {
        return orderNumber;
    }
}
public class OrderController {

    public static void submitOrder(Id orderId) {
        Order__c order = [
            SELECT Id, Order_Number__c, Total_Amount__c,
                   (SELECT Id FROM Line_Items__r)
            FROM Order__c WHERE Id = :orderId
        ];

        try {
            OrderValidator.validate(order);
            order.Status__c = 'Submitted';
            update order;

        } catch (OrderValidationException e) {
            // Surface a specific business message to the LWC
            throw new AuraHandledException(
                'Validation failed for order ' + e.getOrderNumber()
                + ': ' + e.getMessage()
            );

        } catch (DmlException e) {
            System.debug(LoggingLevel.ERROR, e.getStackTraceString());
            throw new AuraHandledException('Could not save order. Please contact support.');
        }
    }
}

AuraHandledException passes your message directly to the LWC error handler. A standard Exception rethrown from an @AuraEnabled method shows only a generic "Script-thrown exception" in the UI.

Exception Handling in Batch Apex

Each execute call in a batch is its own transaction. An unhandled exception in one chunk fails that chunk's DML but the finish method still runs. Wrap your execute body to control per-chunk behaviour.

public class AccountSyncBatch implements Database.Batchable<SObject>,
                                         Database.AllowsCallouts {

    public Database.QueryLocator start(Database.BatchableContext bc) {
        return Database.getQueryLocator([
            SELECT Id, Name, Sync_Status__c FROM Account WHERE Sync_Status__c = 'Pending'
        ]);
    }

    public void execute(Database.BatchableContext bc, List<Account> scope) {
        List<Account> toUpdate = new List<Account>();

        for (Account acc : scope) {
            try {
                Boolean synced = callExternalSystem(acc);
                acc.Sync_Status__c = synced ? 'Synced' : 'Failed';
                toUpdate.add(acc);

            } catch (CalloutException e) {
                acc.Sync_Status__c = 'Error';
                acc.Sync_Error__c  = e.getMessage().left(255);
                toUpdate.add(acc);
                System.debug(LoggingLevel.ERROR,
                    'Callout failed for ' + acc.Id + ': ' + e.getMessage());
            }
        }

        // allOrNone = false: one bad record does not fail the whole chunk
        List<Database.SaveResult> results = Database.update(toUpdate, false);

        for (Integer i = 0; i < results.size(); i++) {
            if (!results[i].isSuccess()) {
                System.debug(LoggingLevel.ERROR,
                    'Update failed for ' + toUpdate[i].Id + ': '
                    + results[i].getErrors()[0].getMessage());
            }
        }
    }

    public void finish(Database.BatchableContext bc) {
        System.debug('Batch complete.');
    }

    private Boolean callExternalSystem(Account acc) {
        HttpRequest req = new HttpRequest();
        req.setEndpoint('callout:ExternalSystem/accounts/' + acc.Id);
        req.setMethod('PUT');
        req.setTimeout(10000);
        HttpResponse res = new Http().send(req);
        return res.getStatusCode() == 200;
    }
}

What You Cannot Catch

LimitException is thrown when a governor limit is exceeded and it cannot be caught — not even by catch (Exception e). The only defence is to avoid hitting the limit: bulk-safe SOQL, bulkified DML, and checking Limits.* methods before the operation.

// Defensive pattern: check limits before expensive operations
public static void safeInsert(List<SObject> records) {
    Integer remaining = Limits.getLimitDmlRows() - Limits.getDmlRows();
    if (records.size() > remaining) {
        throw new OrderValidationException(
            'Cannot insert ' + records.size() + ' records. '
            + 'Only ' + remaining + ' DML rows remaining.',
            null
        );
    }
    insert records;
}

Logging Exceptions to a Custom Object

Debug logs disappear after 24 hours and are not queryable by support teams. A common production pattern writes exceptions to a custom object (Error_Log__c) so you can report on failure trends.

public class ErrorLogger {

    public static void log(Exception e, String context) {
        insertLog(e, context);
    }

    private without sharing class insertLog {
        insertLog(Exception e, String context) {
            try {
                insert new Error_Log__c(
                    Context__c        = context,
                    Exception_Type__c = e.getTypeName(),
                    Message__c        = e.getMessage()?.left(32768),
                    Stack_Trace__c    = e.getStackTraceString()?.left(32768),
                    Occurred_At__c    = Datetime.now()
                );
            } catch (Exception logEx) {
                // Never let logging itself crash the caller
                System.debug(LoggingLevel.ERROR,
                    'ErrorLogger failed: ' + logEx.getMessage());
            }
        }
    }
}
// Usage inside any service
try {
    processRecords(scope);
} catch (Exception e) {
    ErrorLogger.log(e, 'AccountSyncBatch.execute');
    throw e;
}

try / catch in Asynchronous Contexts

In @future methods and Queueable jobs, exceptions that escape the method are silently swallowed by the platform — no error is surfaced to any user. This makes internal exception handling critical in async code: if you do not catch and log explicitly, failures disappear.

public class NotificationQueueable implements Queueable {

    private List<Id> contactIds;

    public NotificationQueueable(List<Id> contactIds) {
        this.contactIds = contactIds;
    }

    public void execute(QueueableContext ctx) {
        try {
            List<Contact> contacts = [
                SELECT Id, Email FROM Contact WHERE Id IN :contactIds
            ];
            sendNotifications(contacts);

        } catch (Exception e) {
            // Without this, failures vanish silently in async context
            ErrorLogger.log(e, 'NotificationQueueable.execute');
        }
    }

    private void sendNotifications(List<Contact> contacts) {
        // ... email or callout logic
    }
}

Best Practices at a Glance

  • Catch specific exception types first; use catch (Exception e) only as a final safety net or in async contexts where silent failure is the alternative.
  • Never swallow exceptions silently (catch (Exception e) {}). At minimum log e.getMessage() and e.getStackTraceString().
  • Prefer Database.insert/update/delete(records, false) over the DML statement form when partial success is acceptable; inspect SaveResult errors instead of catching DmlException.
  • Use finally for cleanup, not business logic. Code in finally should be idempotent and must not throw.
  • Wrap platform exceptions in domain-specific custom exceptions at service boundaries so callers depend on your API, not Salesforce internals.
  • In Batch and Queueable, always wrap the execute body in try/catch and log — async failures are invisible otherwise.
  • Never attempt to catch LimitException; guard with Limits.* methods before hitting the operation.

Frequently Asked Questions

What is the difference between catching DmlException and catching Exception in Apex?

DmlException exposes row-level failure detail through getDmlMessage(index), getDmlStatusCode(index), and getDmlFieldNames(index). Catching the base Exception class loses this specificity. Always place the DmlException catch block before the generic Exception catch so each failed DML row can be inspected individually.

Does the finally block run even when a catch block rethrows an exception?

Yes. The finally block always executes — whether the try completes normally, a catch handles the exception, or a catch rethrows. The finally block runs before the exception propagates to the caller, making it the correct place for cleanup like releasing locks, resetting flags, or writing audit records.

Can you catch a LimitException in Apex?

No. LimitException is thrown when a governor limit is breached and cannot be caught by any catch block, including catch (Exception e). The only defence is to check Limits.* methods — such as Limits.getDmlRows() versus Limits.getLimitDmlRows() — before performing operations that could exceed the limit.

What is AuraHandledException and when should you use it in Apex?

AuraHandledException is a special exception that LWC and Aura components can read without the framework stripping the message. Throwing a standard Exception from an @AuraEnabled method results in a generic "Script-thrown exception" in the UI. Throwing AuraHandledException instead passes your message directly to the component's error handler, letting you surface specific validation or business rule failures to the user.

How do you log exceptions in asynchronous Apex contexts like Queueable or @future?

Always wrap the execute method body in try/catch and log explicitly to a custom object like Error_Log__c. Unhandled exceptions in @future and Queueable jobs are silently swallowed by the platform — no error surfaces to any user or admin. Write the log insert inside a without sharing inner class so it succeeds regardless of the running user's record access.

← All articles