UNKNOWN_EXCEPTION: An unexpected error occurred. Please include this ErrorId if you contact support
Salesforce hit an unexpected internal error and wrapped it as `UNKNOWN_EXCEPTION` with an ErrorId. Different from `UnexpectedException` — `UNKNOWN_EXCEPTION` is the API-layer wrapper. The ErrorId is the only useful clue; file a support case with it.
Also seen asUNKNOWN_EXCEPTION·An unexpected error occurred·Please include this ErrorId·ErrorId support case
A user clicks Save on a quote record. The page returns: "UNKNOWN_EXCEPTION: An unexpected error occurred. Please include this ErrorId if you contact support: 1234567890-12345 (1234567890)". Nothing in the stack trace identifies the cause. The user files a ticket with the error id. Support reaches back hours later asking for a debug log. By then the user has moved on and the trigger fires hundreds more times for other customers.
What the platform is actually telling you
UNKNOWN_EXCEPTION is the catch-all error code the platform returns when something failed inside Apex execution and no more specific exception type fits. The error id is the platform's internal correlation token for that specific failure. Salesforce internal logs index by that id; without it, support cannot find your incident in their telemetry.
The exception is "unknown" from the API client's perspective, not from the platform's. The platform recorded what failed; the message just doesn't surface the details over the API. The actual cause is in the Apex debug log for the user and transaction in question, in the Apex Exception Email if you have one configured, or in the platform's Setup, Logs, Apex Jobs view for async work.
Common underlying causes include uncaught exceptions in Apex (NullPointerException, QueryException, DmlException), exceptions thrown by Apex triggers during the DML the user initiated, governor-limit exceptions in async contexts, and runtime errors in Lightning Web Component imperative Apex calls. Each surfaces the same generic message.
The broken example
A trigger that wraps every quote save with a side-effect:
trigger QuoteTrigger on Quote (before insert, before update) {
for (Quote q : Trigger.new) {
Account acc = [
SELECT Id, OwnerId, Industry, Annual_Revenue__c
FROM Account
WHERE Id = :q.AccountId
LIMIT 1
];
if (acc.Annual_Revenue__c > 1000000) {
q.Discount_Approval_Required__c = true;
q.Approval_Owner__c = acc.Owner.Manager.Id;
}
}
}
The trigger looks reasonable. It works when the account exists and the owner has a manager. It fails in three different ways:
If the user saves a quote without an AccountId, the SOQL returns zero rows and throws System.QueryException: List has no rows for assignment to SObject. The trigger doesn't catch it. The user sees UNKNOWN_EXCEPTION with a fresh error id.
If the account owner has no manager, acc.Owner.Manager.Id dereferences null and throws System.NullPointerException. The trigger doesn't catch it. The user sees a different UNKNOWN_EXCEPTION with a different error id.
If the user saves a hundred quotes at once via a bulk API call, the SOQL inside the loop runs a hundred times. The hundred-and-first iteration throws System.LimitException: Too many SOQL queries: 101. The trigger doesn't catch it. The user sees yet another UNKNOWN_EXCEPTION.
Three different defects, three different error ids, identical user-facing message.
Why the platform hides the detail
The generic message is intentional. Salesforce runs as a multi-tenant platform where the underlying exception text might reveal information about the org's metadata, validation rules, or even other tenants' code (if a managed package threw the exception). Surfacing the raw text could be a security or privacy issue.
The platform's contract is: "Here's an opaque incident id. Use it to retrieve the detail via channels we control (debug log, exception email, support ticket)." The opacity is a feature, not a defect.
For your own code, you can defeat the opacity by catching exceptions explicitly and surfacing what you choose to surface. The Apex code is yours; what your code throws is your choice.
The fix: catch where you can recover, log everything else
Start by wrapping Apex methods that can throw with try/catch blocks and structured logging:
trigger QuoteTrigger on Quote (before insert, before update) {
Set<Id> accountIds = new Set<Id>();
for (Quote q : Trigger.new) {
if (q.AccountId != null) {
accountIds.add(q.AccountId);
}
}
if (accountIds.isEmpty()) return;
Map<Id, Account> accountsById;
try {
accountsById = new Map<Id, Account>([
SELECT Id, OwnerId, Industry, Annual_Revenue__c, Owner.ManagerId
FROM Account
WHERE Id IN :accountIds
]);
} catch (QueryException ex) {
ErrorLogger.log('QuoteTrigger', 'Account query failed', ex);
return;
}
for (Quote q : Trigger.new) {
if (q.AccountId == null) continue;
Account acc = accountsById.get(q.AccountId);
if (acc == null) continue;
if (acc.Annual_Revenue__c == null) continue;
if (acc.Annual_Revenue__c > 1000000 && acc.Owner.ManagerId != null) {
q.Discount_Approval_Required__c = true;
q.Approval_Owner__c = acc.Owner.ManagerId;
}
}
}
Three changes. The SOQL moved out of the loop into a single bulk-safe query keyed on a set of ids. Every dereference is guarded with a null check. The query is wrapped in a try/catch that delegates to an ErrorLogger class.
The user still sees a save proceed (or fail safely if the query couldn't run). The exception, if one occurs, lands in the error log with structured detail.
The ErrorLogger pattern
A reusable logger class that captures exception detail to a custom Error_Log__c object:
public class ErrorLogger {
public static void log(String source, String context, Exception ex) {
Error_Log__c logRow = new Error_Log__c(
Source__c = source,
Context__c = context,
Exception_Type__c = ex.getTypeName(),
Message__c = ex.getMessage(),
Stack_Trace__c = ex.getStackTraceString(),
User__c = UserInfo.getUserId(),
Transaction_Id__c = Request.getCurrent().getRequestId()
);
try {
insert logRow;
} catch (DmlException dmlEx) {
System.debug(LoggingLevel.ERROR, 'Failed to write to Error_Log__c: ' + dmlEx);
}
}
}
Request.getCurrent().getRequestId() returns the platform's internal request id, which matches the error id surfaced to the user. The log row now ties the user-facing incident id to the underlying exception detail. Support can search the log by id and find the root cause in seconds instead of hours.
For very high-volume errors, write the log via a Platform Event instead of direct DML. Platform Events publish even when the surrounding transaction rolls back, which is important because the most useful logs are from the transactions that failed.
The fixed example, end to end
public class ErrorLogger {
@TestVisible
private static List<Error_Log_Event__e> pending = new List<Error_Log_Event__e>();
public static void log(String source, String context, Exception ex) {
pending.add(new Error_Log_Event__e(
Source__c = source,
Context__c = context,
Exception_Type__c = ex.getTypeName(),
Message__c = String.valueOf(ex.getMessage()).left(255),
Stack_Trace__c = String.valueOf(ex.getStackTraceString()).left(32768),
User_Id__c = UserInfo.getUserId(),
Request_Id__c = Request.getCurrent().getRequestId()
));
}
public static void flush() {
if (pending.isEmpty()) return;
EventBus.publish(pending);
pending.clear();
}
}
trigger QuoteTrigger on Quote (before insert, before update) {
try {
QuoteTriggerHandler.run(Trigger.new);
} catch (Exception ex) {
ErrorLogger.log('QuoteTrigger', JSON.serialize(Trigger.new), ex);
throw ex;
} finally {
ErrorLogger.flush();
}
}
The trigger catches anything that escapes the handler, logs it with the affected records, and re-throws so the DML still rolls back. The finally block ensures the platform-event publish happens regardless of success. The user sees the UNKNOWN_EXCEPTION on screen; the developer sees the actual cause in the event log.
Apex Exception Email
Setup, User Interface, Apex Exception Email lets you configure addresses that receive every uncaught Apex exception in the org. The email contains the stack trace, the user, the timestamp, and the request id.
For most orgs, configuring the address to a shared dev team inbox (or a Slack channel via email-to-Slack integration) is the single most useful change you can make for production debugging. Every uncaught exception becomes visible to the team immediately. The error id correlation problem solves itself because the email contains both the user's id and the exception detail.
The trade-off is volume. A buggy production trigger can fire thousands of times per day, each generating a separate email. Filter the inbox by exception class to keep the noise manageable.
Reading the debug log when the email isn't configured
When a specific user is hitting UNKNOWN_EXCEPTION and you want to capture the exception detail:
- Setup, Debug Logs, add the user as a tracked user.
- Ask the user to reproduce the error.
- Find the log entry in Setup, Debug Logs, filter by user.
- Search the log for
EXCEPTION_THROWN. The line above it shows the exact line of Apex that threw.
The user-side experience is unchanged. The debug log fills in the missing detail.
For async work (batches, queueables, future methods), check Setup, Apex Jobs. Failed async jobs surface their exception in the job's Status field.
When the exception comes from a managed package
If the unknown exception originates inside a managed package, your debug logs show the package's namespace prefix in the stack trace but not the package's source code. You can see where the exception came from but not why.
The remediation path:
- Capture the exception detail in your logs (the message, the type, the stack trace).
- File a case with the package vendor including the error id and the captured detail.
- Reproduce in a sandbox without the package isolated to confirm whether the cause is package-side or in your code's interaction with the package.
Until the vendor responds, you can usually work around the issue by catching the exception at the boundary where your code calls the package, logging, and degrading gracefully.
Closely related errors
| Error | Cause |
|---|---|
UNKNOWN_EXCEPTION | Uncaught Apex exception, no more-specific type fits |
INTERNAL_SERVER_ERROR | Platform-side issue, often resolves on retry |
OPERATION_TOO_LARGE | Single API call exceeded a size limit |
REQUEST_RUNNING_TOO_LONG | Synchronous transaction exceeded its time budget |
The first is your code or a downstream class. The second is the platform's. The third and fourth are size and time governance.
The error id format and what support sees
The error id surfaced to the user is a two-part token of the form <long-number>-<short-number> (<short-number>). The first long number is the request id. The second short number is the line offset where the exception originated. The trailing parenthetical is a hash that ties the request to internal log entries.
When support pulls the incident, they search their telemetry by the request id. If your user clicks Save and gets the message but doesn't capture the id, support has to rely on timestamp and user id to find the trace, which is much slower.
Train your users to copy the entire error message verbatim into the support ticket. The cleaner the id capture, the faster the diagnosis. For Lightning Communities and customer-facing portals, customize the error page (Setup, Communities, your community, Pages) to make the error id easier to copy.
Test patterns for trigger handlers
A test that exercises the bulk-failure path:
@isTest
static void quoteTriggerHandlesNullAccountIdSafely() {
List<Quote> quotes = new List<Quote>();
for (Integer i = 0; i < 200; i++) {
quotes.add(new Quote(Name = 'Bulk Test ' + i));
}
Test.startTest();
Database.SaveResult[] results = Database.insert(quotes, false);
Test.stopTest();
for (Database.SaveResult sr : results) {
System.assert(sr.isSuccess() || !sr.getErrors().isEmpty());
}
}
The test inserts 200 quotes without account ids. The bulk-safe handler tolerates the missing accounts. If a future refactor reintroduces a per-row SOQL, the test fails on governor limits at 101 rows. The regression surfaces at deploy time.
Defensive habits
Configure the Apex Exception Email to a monitored channel. Every uncaught exception becomes immediately visible.
Wrap every trigger handler in a try/catch that logs and re-throws. The DML rollback still happens; the diagnostic detail is captured.
Build a reusable ErrorLogger that includes the platform's request id. Tie the user-facing error id to your internal log row.
Bulkify everything. Most UNKNOWN_EXCEPTION incidents in production are governor-limit failures from loops that worked fine in unit tests but explode under bulk DML.
Treat the error id as a unique incident. Don't dismiss recurring incidents until you've captured the underlying exception type and remediated.
Further reading from Salesforce
Related dictionary terms
Share this fix
Related Apex errors
Initial term of field expression must be a concrete SObject: <type>
ApexYou wrote `someThing.Field__c` where `someThing` isn't a specific SObject type — usually an `SObject` or `Object` reference. Apex needs the …
Method does not exist or incorrect signature
ApexThe Apex compiler can't find a method matching the call you wrote — wrong name, wrong argument types, or wrong number of arguments. The comp…
MIXED_DML_OPERATION: DML operation on setup object is not allowed after you have updated a non-setup object (or vice versa)
ApexSalesforce splits objects into "setup" (User, Group, GroupMember, PermissionSet, Profile, Queue, etc.) and "non-setup" (everything else). A …
System.AsyncException: Future method cannot be called from a future or batch method
ApexYou called an `@future` method from inside another `@future`, batch, or queueable. Salesforce blocks recursive async chains because they cou…
System.CalloutException: Read timed out
ApexThe HTTP callout exceeded its allowed read time waiting for the remote server's response. Salesforce caps a single callout at 120 seconds (d…