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 failsNullPointerException— fired when dereferencing a null referenceLimitException— fired when a governor limit is breached; cannot be caughtSObjectException— fired when accessing a field not in the SELECT listTypeException— fired on invalid type castsMathException— fired on division by zeroEmailException— fired by Messaging failuresCalloutException— fired by failed HTTP calloutsJSONException— 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 messagegetTypeName()— the exception class name as a StringgetStackTraceString()— full stack trace as a StringgetCause()— the wrapped inner exception, ornullinitCause(Exception cause)— wraps an inner exception before rethrowing
And the methods unique to DmlException:
getNumDml()— number of rows that failedgetDmlMessage(index)— error message for a specific rowgetDmlStatusCode(index)— Salesforce error status code string (e.g.FIELD_CUSTOM_VALIDATION_EXCEPTION)getDmlFieldNames(index)— list of field API names involved in the failuregetDmlId(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 loge.getMessage()ande.getStackTraceString(). - Prefer
Database.insert/update/delete(records, false)over the DML statement form when partial success is acceptable; inspectSaveResulterrors instead of catchingDmlException. - Use
finallyfor cleanup, not business logic. Code infinallyshould 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
executebody intry/catchand log — async failures are invisible otherwise. - Never attempt to catch
LimitException; guard withLimits.*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.