Not verified hands-on. This article was written from primary documentation rather than reproduced in an org, so it carries no tested-release badge. If you run it and something differs, say so in the comments and it gets tested and corrected.

The short version: a Finalizer is a class attached to a Queueable job that runs after the job finishes, whether it succeeded or died on an uncatchable exception. It is the only way to log or recover from a governor-limit failure in async Apex, because a try/catch inside the job cannot catch a LimitException that kills the transaction. Attach one with System.attachFinalizer() in the job’s execute method.

The problem Finalizers exist to solve

A Queueable that blows the CPU limit does not reach your catch block. The transaction is torn down, any DML is rolled back, and what you get is a row in the Apex Jobs list with a status of Failed and an error message nobody is watching. Logging from inside the job cannot help, because the log write is rolled back with everything else.

A Finalizer runs in a separate transaction after the job completes. It gets a fresh set of governor limits, and its DML commits independently of the job’s. That is the whole trick.

The minimum implementation

public class JobLogger implements Finalizer {

    private final String jobName;

    public JobLogger(String jobName) {
        this.jobName = jobName;
    }

    public void execute(FinalizerContext ctx) {
        Id parentJobId = ctx.getAsyncApexJobId();

        if (ctx.getResult() == ParentJobResult.SUCCESS) {
            insert new Job_Log__c(
                Name = jobName, Job_Id__c = parentJobId, Status__c = 'Success'
            );
        } else {
            insert new Job_Log__c(
                Name       = jobName,
                Job_Id__c  = parentJobId,
                Status__c  = 'Failed',
                Error__c   = ctx.getException() == null
                             ? 'Unknown' : ctx.getException().getMessage()
            );
        }
    }
}

And attaching it:

public class ShipmentSyncJob implements Queueable {
    public void execute(QueueableContext ctx) {
        System.attachFinalizer(new JobLogger('ShipmentSync'));
        // ... the work that might fail
    }
}

The attach call goes inside execute, not in the constructor and not at enqueue time. One finalizer per job. Attaching a second throws.

What FinalizerContext gives you

Method What it returns
getAsyncApexJobId() The Id of the job that just ran
getRequestId() The request Id, for correlating with debug logs
getResult() ParentJobResult.SUCCESS or UNHANDLED_EXCEPTION
getException() The exception that killed the job, or null on success

getRequestId() is the underrated one. Store it on the log record and you can line the failure up against the debug log for the same request, which turns “it failed sometime last night” into a specific log to open.

The retry pattern, and its one dangerous edge

Because a Finalizer can enqueue, the obvious use is re-running the failed job.

public void execute(FinalizerContext ctx) {
    if (ctx.getResult() == ParentJobResult.UNHANDLED_EXCEPTION && attempt < 3) {
        System.enqueueJob(new ShipmentSyncJob(recordIds, attempt + 1));
    }
}

Carry the attempt count in the job’s own state and increment it. Without a bound, a job that fails deterministically (bad data, a null reference, a limit you will hit every time) re-enqueues itself forever, and each attempt consumes daily async capacity that the rest of the org shares.

I would cap retries at two or three and write the failure to a record on the last attempt rather than retrying indefinitely, because the failures that are worth retrying are transient ones, and transient failures almost never need a fourth go.

Limits inside a Finalizer

A Finalizer gets its own transaction and its own limits, but it is not unconstrained. It can enqueue exactly one Queueable job. It has the standard per-transaction ceilings for DML and SOQL. And a Finalizer that throws leaves you with nothing (no log, no retry), so its body should be defensive to the point of dullness.

public void execute(FinalizerContext ctx) {
    try {
        // logging, retry decision
    } catch (Exception e) {
        System.debug(LoggingLevel.ERROR, 'Finalizer failed: ' + e.getMessage());
    }
}

Swallowing an exception is normally bad practice. Here it is correct: a finalizer that propagates has already lost the information it existed to capture.

Where this beats the alternatives

Before Finalizers, the honest options were a platform event published before the risky work (which survives rollback, but fires whether or not anything went wrong) or a scheduled job that reads AsyncApexJob looking for failures (which works, and is minutes late). Finalizers give you the failure, the exception, and the request Id, immediately, without either compromise.

They do not replace try/catch. Catchable exceptions (a DML failure, a callout error, a null reference you anticipated) should still be handled inside the job where you have the context to recover. Finalizers are for what try/catch cannot reach.

Frequently asked

Do Finalizers work with Batch Apex?

No. System.attachFinalizer applies to Queueable jobs. Batch Apex has its own finish method, which runs after all chunks complete — but note the difference: finish runs after a batch whose individual chunks may have failed silently, so it is a weaker guarantee than a Finalizer’s.

Does the Finalizer run if the job hits the CPU limit?

Yes, and that is the main reason to use one. The job’s transaction is rolled back, the Finalizer runs afterwards in its own transaction, and getException() gives you the LimitException.

Can I attach more than one Finalizer?

No. A second attachFinalizer call in the same transaction throws. If you need several behaviours, compose them inside one Finalizer class.

Is the job’s DML rolled back before the Finalizer runs?

Yes. On an unhandled exception the job’s transaction rolls back completely, so the Finalizer sees a database with none of the job’s writes in it. Any state you want the Finalizer to have must be passed into it as fields on the Finalizer instance.

How do I test one?

Enqueue the job inside Test.startTest() and Test.stopTest(), which forces async work to run, then assert on the log records the Finalizer wrote. Testing the failure path means making the job fail deliberately — a division by zero or a deliberate null dereference is the usual approach.


Reference: Transaction Finalizers and Queueable Apex, Salesforce Apex Developer Guide.