Salesforce Dictionary - Free Salesforce GlossarySalesforce Dictionary
All errors
Apex

System.UnexpectedException: salesforce system error / Apex script unhandled exception

An internal Salesforce error you can't fix from your code. Often a platform bug, a temporary infrastructure issue, or an edge case in how the platform serialises Apex state. The right action is usually to file a Salesforce support case with the request ID, not to keep debugging.

Also seen asUnexpectedException·salesforce system exception·Apex script unhandled exception·System.UnexpectedException: salesforce

A queueable job that posts Opportunity updates to an external order management system has run cleanly for two weeks. The Monday morning queue shows three failed jobs from the overnight batch with System.UnexpectedException: salesforce system error. The Apex Jobs page reports the same opaque message. The debug logs are truncated. The developer has no stack trace to read and no record id to start from. Production is currently dropping orders.

What the platform is checking

System.UnexpectedException: salesforce system error is the catch-all the platform raises when an internal error fires below the visible Apex layer. The exception means: something failed inside the Apex runtime or in a dependent service, in a way that did not match a known, named exception. The runtime captures the failure, rolls back the transaction, and surfaces this message because no more specific one applies.

The error is not a bug in your Apex code per se. The bug is that the platform encountered a condition it could not handle gracefully. The cause is almost always in one of these categories:

A bug in the Apex runtime itself (rare, but real, especially when a new feature is recently released).

A misuse of a Salesforce-provided method that triggers an internal error path. Examples include Database.update(...) with malformed records, JSON.deserialize against malformed JSON, Crypto.sign with a wrong key length, or schema describe calls on objects that are in an intermediate state.

A platform service that the transaction depends on returned an unexpected result. Outbound API callouts that fail with weird status codes, OAuth tokens that expire mid-call, or external IDs that conflict in unpredictable ways can all bubble up as this exception.

A timeout or resource constraint that the platform does not have a named exception for. Memory pressure inside the JVM, database lock timeouts, or storage tier transitions can all produce this message.

The error message is generic and the stack trace is often unhelpful because the failure happened outside the user-visible Apex frame. Diagnosing the cause requires the system context: the time of the failure, the queueable or batch identifier, recent platform changes, and any pattern across multiple occurrences.

The broken example

A queueable that posts Opportunity updates to an external system:

public class OpportunitySyncQueueable implements Queueable, Database.AllowsCallouts {
    private List<Id> opportunityIds;

    public OpportunitySyncQueueable(List<Id> ids) {
        this.opportunityIds = ids;
    }

    public void execute(QueueableContext qc) {
        List<Opportunity> opps = [
            SELECT Id, Name, Amount, StageName
            FROM Opportunity
            WHERE Id IN :opportunityIds
        ];
        for (Opportunity o : opps) {
            HttpRequest req = new HttpRequest();
            req.setEndpoint('callout:OrderSystem/orders');
            req.setMethod('POST');
            req.setBody(JSON.serialize(o));
            HttpResponse res = new Http().send(req);
            o.Sync_Status__c = res.getStatusCode() == 200 ? 'Sent' : 'Failed';
        }
        update opps;
    }
}

The job runs fine for two weeks. One Monday, three of the records produce System.UnexpectedException: salesforce system error. The pattern is hard to spot: the failing records have nothing obvious in common. Two are large Opportunities; one is small. They were created on different days. The serialization looks correct.

Investigation later reveals the cause: one Opportunity has a custom field with a value containing a malformed UTF-8 sequence (inserted by a buggy upstream system). JSON.serialize encounters the bad bytes, the serializer enters a code path that the runtime cannot recover from, and the transaction aborts with the generic exception.

A second shape: a synchronous Apex method that uses Schema.getGlobalDescribe() and then references an SObject type that was deleted in the same session. The describe map captures the type, but the metadata service has already removed the underlying definition. The platform raises the generic exception when the next operation tries to act on the stale describe result.

A third shape: a batch that runs while the Salesforce instance is undergoing a release upgrade. A small percentage of records fail with this exception because the underlying service swapped versions mid-transaction. The next run of the batch (after the release completes) processes the same records cleanly.

The fix, three paths

Add defensive try-catch with structured logging. When the cause is unpredictable, the priority is observability. Wrap each unit of work in its own try-catch so a single failure does not abort the batch. Log the record id, the action, and the exception type and message.

public void execute(QueueableContext qc) {
    List<Opportunity> opps = [
        SELECT Id, Name, Amount, StageName
        FROM Opportunity
        WHERE Id IN :opportunityIds
    ];
    List<Opportunity> updates = new List<Opportunity>();
    for (Opportunity o : opps) {
        try {
            HttpRequest req = new HttpRequest();
            req.setEndpoint('callout:OrderSystem/orders');
            req.setMethod('POST');
            req.setBody(JSON.serialize(o));
            HttpResponse res = new Http().send(req);
            o.Sync_Status__c = res.getStatusCode() == 200 ? 'Sent' : 'Failed';
            updates.add(o);
        } catch (Exception e) {
            o.Sync_Status__c = 'Error';
            o.Sync_Error__c = e.getTypeName() + ': ' + e.getMessage();
            updates.add(o);
            System.debug(LoggingLevel.ERROR,
                'Sync failed for Opportunity ' + o.Id + ': ' + e.getMessage());
        }
    }
    if (!updates.isEmpty()) {
        update updates;
    }
}

The catch block runs for any exception, including the generic system error. The record is marked failed, the message is captured, and the rest of the batch continues.

Sanitize inputs that could trip the runtime. When the cause turns out to be malformed data, the long-term fix is to validate or sanitize at the boundary where the data enters the system.

private String safeSerialize(Opportunity o) {
    Map<String, Object> clean = new Map<String, Object>{
        'Id' => o.Id,
        'Name' => sanitize(o.Name),
        'Amount' => o.Amount,
        'StageName' => sanitize(o.StageName)
    };
    return JSON.serialize(clean);
}

private String sanitize(String s) {
    if (s == null) return null;
    return s.replaceAll('[^\\p{Print}\\p{L}]', '');
}

The serializer now sees only well-formed text. The malformed UTF-8 sequence that originally tripped the platform is filtered out before it reaches JSON.serialize.

Retry transient failures. When the cause is platform-side and intermittent, a retry pattern resolves most occurrences. Mark the failing record, requeue the job after a delay, and try again.

public class OpportunitySyncQueueable implements Queueable, Database.AllowsCallouts {
    private List<Id> ids;
    private Integer attempt;

    public OpportunitySyncQueueable(List<Id> ids, Integer attempt) {
        this.ids = ids;
        this.attempt = attempt == null ? 1 : attempt;
    }

    public void execute(QueueableContext qc) {
        List<Id> failed = new List<Id>();
        // ... do work, populating failed list on exception ...
        if (!failed.isEmpty() && attempt < 3) {
            System.enqueueJob(new OpportunitySyncQueueable(failed, attempt + 1));
        }
    }
}

The retry capped at 3 attempts gives transient failures a chance to clear without spinning forever.

The fixed example

A queueable with all three patterns:

public class OpportunitySyncQueueable implements Queueable, Database.AllowsCallouts {
    private List<Id> opportunityIds;
    private Integer attempt;

    public OpportunitySyncQueueable(List<Id> ids, Integer attempt) {
        this.opportunityIds = ids;
        this.attempt = attempt == null ? 1 : attempt;
    }

    public void execute(QueueableContext qc) {
        List<Opportunity> opps = [
            SELECT Id, Name, Amount, StageName
            FROM Opportunity
            WHERE Id IN :opportunityIds
        ];
        List<Opportunity> updates = new List<Opportunity>();
        List<Id> toRetry = new List<Id>();

        for (Opportunity o : opps) {
            try {
                HttpRequest req = new HttpRequest();
                req.setEndpoint('callout:OrderSystem/orders');
                req.setMethod('POST');
                req.setBody(safeSerialize(o));
                HttpResponse res = new Http().send(req);
                Integer status = res.getStatusCode();
                if (status >= 200 && status < 300) {
                    o.Sync_Status__c = 'Sent';
                } else if (status >= 500) {
                    toRetry.add(o.Id);
                    o.Sync_Status__c = 'Retry';
                } else {
                    o.Sync_Status__c = 'Failed';
                    o.Sync_Error__c = 'HTTP ' + status;
                }
                updates.add(o);
            } catch (Exception e) {
                o.Sync_Status__c = attempt < 3 ? 'Retry' : 'Error';
                o.Sync_Error__c = e.getTypeName() + ': ' + e.getMessage();
                if (attempt < 3) toRetry.add(o.Id);
                updates.add(o);
                System.debug(LoggingLevel.ERROR,
                    'Sync attempt ' + attempt + ' failed for ' + o.Id + ': ' + e.getMessage());
            }
        }

        if (!updates.isEmpty()) update updates;
        if (!toRetry.isEmpty() && attempt < 3 && !Test.isRunningTest()) {
            System.enqueueJob(new OpportunitySyncQueueable(toRetry, attempt + 1));
        }
    }

    private String safeSerialize(Opportunity o) {
        return JSON.serialize(new Map<String, Object>{
            'Id' => o.Id,
            'Name' => sanitize(o.Name),
            'Amount' => o.Amount,
            'StageName' => sanitize(o.StageName)
        });
    }

    private String sanitize(String s) {
        if (s == null) return null;
        return s.replaceAll('[^\\p{Print}\\p{L}]', '');
    }
}

Every failure is caught, logged, and either retried or marked permanently failed. The serializer sanitizes inputs. The retry count caps the chain.

Edge case: the error fires before your code runs

When the queueable or batch infrastructure itself raises this error, your try-catch never executes. The framework reports the failure on the Apex Jobs page with the same generic message. Diagnosing requires checking the Salesforce status page, opening a case with Salesforce Support, and checking whether the same job class has failed across multiple jobs in a short window.

If multiple jobs of the same class fail with the same generic error, the cause is usually outside your code: a release window, an instance migration, or a platform incident. Salesforce Trust (trust.salesforce.com) reports active incidents per instance.

Edge case: serialization of large objects

JSON.serialize and JSON.deserialize can produce this error when the input is too large or deeply nested. The official heap limit is 6 MB synchronous, 12 MB async. A serialized payload that approaches the limit can fail unpredictably. Strip unused fields, paginate large collections, and serialize chunks separately when the source is genuinely large.

Edge case: triggers that fire during platform maintenance

Triggers that execute during a maintenance window may see partial schema or partial service availability. The defensive pattern is to gate side-effects on a feature flag or to write the failure to a queue for later replay. The trigger continues to fire, and the system reconciles the missed work after maintenance ends.

Edge case: tooling API operations

Tooling API operations (deploying metadata, running tests via the API, inspecting symbols) sometimes produce this exception when the underlying tooling service has an internal issue. Retrying after a short backoff usually clears the problem. Persistent failures point at an actual tooling-side bug or a queued operation that has been stuck.

Edge case: cross-org data sync via Salesforce Connect

External objects backed by Salesforce Connect surface remote data as queryable objects. When the remote system is unhealthy, queries against the external object can fail with this generic exception. Diagnosis requires examining the named credential's authentication state and the remote system's status.

Edge case: Apex Heap size and serializer interaction

A serializer that consumes 5 MB of heap on one record can fail near the 6 MB synchronous limit on the second record. The exception that fires at the boundary is sometimes the named LimitException: Apex heap size too large, but in some edge cases the runtime raises the generic UnexpectedException because the heap exhaustion happens inside a service call rather than inside Apex itself. Profiling heap usage with Limits.getHeapSize() identifies the trend.

Defensive habits

Wrap every external boundary (callouts, JSON parsing, dynamic Apex) in a try-catch. The generic system error is more common at these boundaries than inside pure Apex business logic.

Log structured errors with the record id, the operation name, the timestamp, and the exception type and message. A custom Sync_Log__c object or a Platform Event channel gives you a queryable history of failures.

Test with intentionally malformed inputs. JSON.deserialize tests should include garbage strings, oversized payloads, and edge characters. The tests do not prevent the runtime error, but they exercise the catch and confirm the fallback logic is correct.

Set up alerting on repeated failures. A single occurrence of this error is usually noise. Five or more occurrences in the same hour for the same job class is a signal that something is wrong with the data or the platform.

Test patterns

A test that exercises the catch path:

@IsTest
static void syncHandlesCalloutException() {
    Account a = new Account(Name='Test');
    insert a;
    Opportunity o = new Opportunity(
        Name='Test Opp', AccountId=a.Id, StageName='Prospecting',
        CloseDate=Date.today().addDays(30), Amount=1000
    );
    insert o;

    Test.setMock(HttpCalloutMock.class, new FailingMock());

    Test.startTest();
    System.enqueueJob(new OpportunitySyncQueueable(new List<Id>{o.Id}, 1));
    Test.stopTest();

    Opportunity refreshed = [SELECT Sync_Status__c, Sync_Error__c FROM Opportunity WHERE Id = :o.Id];
    System.assertEquals('Retry', refreshed.Sync_Status__c);
}

private class FailingMock implements HttpCalloutMock {
    public HttpResponse respond(HttpRequest req) {
        throw new CalloutException('Simulated transient failure');
    }
}

The mock raises a CalloutException. The queueable's catch sets the status to Retry. The assert confirms the recovery path.

Diagnosing in production

When the generic exception fires:

  1. Capture the job id, time, and any record id from the debug log.
  2. Check Salesforce Trust for active incidents on your instance.
  3. Re-run the failed job in a sandbox with the same input.
  4. If reproducible, narrow down to the failing record and inspect its data for malformed bytes or unusual content.
  5. If not reproducible, treat as transient and rely on retries.
  6. If the failure persists across retries and across multiple records, open a case with Salesforce Support with the org id, the job id, and the timestamp.

The generic nature of the exception means Salesforce Support's involvement is often required to find the root cause. Capturing the surrounding context (debug log, record id, recent deployments) shortens the support investigation.

Quick recovery checklist

  1. Catch the exception at the unit-of-work boundary.
  2. Log enough context to investigate.
  3. Mark the record as failed or retry.
  4. Sanitize inputs if a pattern in the failing records is visible.
  5. Open a Salesforce case if the failure persists.

The generic system exception is rare and usually transient. The right code structure makes it survivable without dropping work.

Further reading from Salesforce

Related dictionary terms

Share this fix

Share on LinkedInShare on X

Related Apex errors