Salesforce Dictionary - Free Salesforce GlossarySalesforce Dictionary
All errors
Apex

The Apex job named "<name>" is already scheduled for execution

You called `System.schedule(name, ...)` with a name that's already in use. The platform refuses duplicate-name schedules. Either abort the existing one first, or pick a unique name (e.g., suffix with timestamp).

Also seen asalready scheduled for execution·Apex job is already scheduled·schedule name conflict

Your post-deploy script ran cleanly in the staging org. The same script in production returns The Apex job named "Nightly_Reconciliation" is already scheduled for execution, and the deploy log lights up red. The job is fine. It's running. It ran last night. You wrote the deploy script to schedule it, and the platform refuses because something with that name is already on the cron.

The uniqueness rule

System.schedule(name, cronExpression, jobInstance) requires the name argument to be unique across all currently-active scheduled Apex jobs in the org. The check is exact-match on the name string. If a CronTrigger row already exists with CronJobDetail.Name = name and State is anything other than DELETED, the platform refuses to create a duplicate and throws this error.

The check is per-org, not per-namespace. A managed package's scheduled job with the same name doesn't conflict (it lives in the package namespace), but two unmanaged classes can absolutely conflict. Most appearances of this error come from a deploy script that re-runs and tries to schedule a job that the previous run already scheduled.

Why the rule exists

If two jobs with the same name could coexist, abort and cancel operations would be ambiguous. System.abortJob(jobId) works by id, not name, but admin tooling and Apex code often looks up jobs by name. A duplicate name would mean "which one did you mean?" and the platform doesn't want to answer that question. Refusing the duplicate at creation time keeps every named job uniquely addressable.

The classic broken scenario

A deploy script schedules a reconciliation job at the end of every release:

// In a one-time deploy class:
public class DeployRunOnce {
    public static void run() {
        System.schedule(
            'Nightly_Reconciliation',
            '0 0 2 * * ?',
            new ReconciliationJob()
        );
    }
}

The first deploy runs cleanly. The next deploy a week later runs the same script. The platform throws because the schedule from the first deploy is still active. The deploy fails. The team scrambles to figure out whether the failure means "the job didn't get scheduled this time" (it did, from last time) or "the job is missing" (it isn't).

Fix 1: abort the old one, then schedule the new one

The reliable pattern is to remove the existing schedule before creating the new one. This makes the deploy script idempotent: it produces the same final state regardless of whether the schedule existed before.

public class DeployRunOnce {
    public static void run() {
        String jobName = 'Nightly_Reconciliation';

        // Cancel any existing schedule with this name.
        for (CronTrigger existing : [
            SELECT Id
            FROM CronTrigger
            WHERE CronJobDetail.Name = :jobName
        ]) {
            System.abortJob(existing.Id);
        }

        // Now schedule fresh.
        System.schedule(jobName, '0 0 2 * * ?', new ReconciliationJob());
    }
}

System.abortJob works on any job id, whether or not the job is currently executing. If the job is mid-run, the abort cancels the next iteration but lets the current run finish; the schedule slot frees as soon as the abort commits.

The pattern reads as "always go through this state transition, regardless of the starting state." That's the definition of idempotent, and it's the right pattern for any deploy automation that creates persistent platform state.

Fix 2: pick a unique name

If you don't want to abort, suffix the name with something unique:

String uniqueName = 'Nightly_Reconciliation_' + System.now().getTime();
System.schedule(uniqueName, '0 0 2 * * ?', new ReconciliationJob());

This sidesteps the conflict but accumulates schedules. Each deploy adds one more. After ten deploys, you have ten copies of the same job all running nightly. The pattern is fine for one-shot deferred work but wrong for persistent recurring schedules.

It's also a footgun against the 100 scheduled job cap. The platform allows up to 100 active scheduled Apex jobs per org. Accumulating one per deploy is a slow march toward that cap, at which point you can't schedule anything new at all. The error there is different (Maximum number of active Scheduled Apex jobs allowed) but the cause is the same poor naming hygiene.

Fix 3: use scheduleBatch for one-shot deferred work

For genuinely one-time deferred work (run a job once, five minutes from now, then never again), System.scheduleBatch is purpose-built:

Id jobId = System.scheduleBatch(
    new MyBatch(),
    'OneTime_' + UserInfo.getUserId() + '_' + System.now().getTime(),
    5  // minutes from now
);

The job runs once at the specified offset, and the schedule auto-cleans after it runs. No persistent CronTrigger row accumulates. Use this for "run this in 5 minutes after the deploy finishes" patterns.

The fixed example: a deploy script that survives re-runs

A complete post-deploy class that handles three different scheduled jobs idempotently:

public class PostDeploySchedules {
    private static final Map<String, String> JOBS = new Map<String, String>{
        'Nightly_Reconciliation' => '0 0 2 * * ?',
        'Hourly_Refresh' => '0 0 * * * ?',
        'Weekly_Cleanup' => '0 0 3 ? * SUN'
    };

    public static void apply() {
        // 1. Abort any existing instances of these jobs.
        Set<String> names = JOBS.keySet();
        for (CronTrigger existing : [
            SELECT Id, CronJobDetail.Name
            FROM CronTrigger
            WHERE CronJobDetail.Name IN :names
              AND State IN ('WAITING', 'ACQUIRED', 'EXECUTING')
        ]) {
            System.abortJob(existing.Id);
        }

        // 2. Re-schedule each job at its canonical cron expression.
        for (String name : JOBS.keySet()) {
            Schedulable job = scheduleableFor(name);
            System.schedule(name, JOBS.get(name), job);
        }
    }

    private static Schedulable scheduleableFor(String name) {
        if (name == 'Nightly_Reconciliation') return new ReconciliationJob();
        if (name == 'Hourly_Refresh') return new RefreshJob();
        if (name == 'Weekly_Cleanup') return new CleanupJob();
        throw new IllegalArgumentException('Unknown job: ' + name);
    }
}

Call PostDeploySchedules.apply() from any deploy hook. Run it once or run it a hundred times; the org ends up in the same final state.

Diagnosing the existing schedules

The fastest way to see what's currently scheduled is a SOQL query against CronTrigger:

SELECT Id,
       CronJobDetail.Name,
       CronExpression,
       NextFireTime,
       State,
       TimesTriggered,
       OwnerId
FROM CronTrigger
WHERE CronJobDetail.JobType = '7'    -- 7 = Scheduled Apex
ORDER BY CronJobDetail.Name

Run this in the Developer Console's Query Editor. Each row is one scheduled job. The JobType = '7' filter excludes other kinds of cron-driven work (queueables, batches that scheduled themselves, etc.).

State values to recognize:

  • WAITING: scheduled and waiting for the next fire time.
  • ACQUIRED: the platform has reserved the next execution.
  • EXECUTING: currently running.
  • COMPLETE: a one-shot job that already ran.
  • ERROR: the last execution failed; the schedule continues unless explicitly aborted.

A job in ERROR state still counts for the unique-name check, so a previous failed run doesn't free its slot automatically.

Test class implications

When a test class calls System.schedule, the schedule is created in the test transaction. The test framework usually rolls back at Test.stopTest(), but only for DML on regular SObjects. Scheduled Apex bookkeeping is special.

Wrap the schedule call in Test.startTest() / Test.stopTest() so the platform commits the schedule and the test framework can verify the side effect:

@isTest
static void reconciliationJob_scheduledNightly() {
    Test.startTest();
    String jobId = System.schedule('Test_Recon', '0 0 2 * * ?', new ReconciliationJob());
    Test.stopTest();

    CronTrigger ct = [SELECT CronExpression FROM CronTrigger WHERE Id = :jobId];
    System.assertEquals('0 0 2 * * ?', ct.CronExpression);
}

After the test finishes, the framework rolls back the schedule. The CronTrigger row no longer exists, and the name is free for the next test. If you see "already scheduled" inside a test run, you probably forgot the Test.startTest() wrapper or you're running multiple tests in parallel that share a job name.

A subtle source: scheduled tests competing on the same name

Two parallel test methods that both call System.schedule('SharedName', ...) conflict in the same way as production code. The Apex test runner can run tests in parallel by default; two tests scheduling the same name throw the same error.

The fix is to make every test use a unique job name, often by appending a UUID or a counter:

String testJobName = 'Recon_Test_' + System.now().getTime() + '_' + Math.mod(Math.abs(Crypto.getRandomLong()), 100000);
System.schedule(testJobName, '0 0 2 * * ?', new ReconciliationJob());

Apply the same naming hygiene in test code as in production code, and the parallel-test conflict goes away.

Schedule chaining via System.scheduleBatch

A common pattern for jobs that need to run on irregular intervals is "schedule the next run from inside the current run." After the batch finishes, queue the next one with a unique name:

public class ContinuousReconciliation implements Schedulable {
    public void execute(SchedulableContext sc) {
        // Do the work.
        ReconciliationService.run();

        // Schedule the next run 30 minutes from now.
        String nextName = 'Recon_' + System.now().addMinutes(30).getTime();
        System.scheduleBatch(new ContinuousReconciliation(), nextName, 30);
    }
}

The pattern is useful when you want sub-hourly cadence (the Salesforce cron expression doesn't go below 1-minute granularity, and very dense crons are discouraged). Each iteration spawns the next with a distinct name, so the unique-name conflict never fires.

The trade-off is that the chain can break silently: if one iteration fails before reaching the schedule line, the chain stops. Pair this pattern with a separate, lower-frequency "health check" schedule that confirms the chain is still alive and restarts it if not.

The 100-job cap is closer than it looks

The "Maximum number of active Scheduled Apex jobs allowed" cap is 100 per org. Counts toward the cap:

  • Every job created via System.schedule, regardless of cadence.
  • Every job created via System.scheduleBatch that hasn't yet run.
  • Jobs in ERROR state that haven't been aborted.

Does not count:

  • Queueable Apex jobs (those have their own 50-jobs-in-flex-queue limit).
  • Batch Apex executions (governed by the 5-concurrent-batch cap).
  • Future calls (24-hour rolling cap of 250,000).

A mature org with multiple feature teams, each scheduling 5-10 maintenance jobs, can reach 100 fast. Adopt a naming convention that includes the team or feature name (PaymentTeam_Reconciliation_Nightly) so the team that owns each schedule is obvious from the SOQL output.

What aborting actually does

System.abortJob(jobId) does three things:

  1. Removes the job from the active scheduled-job list, freeing its slot for the 100-cap.
  2. Sets the CronTrigger.State to DELETED (the row stays in the database for audit but no longer counts for unique-name conflicts).
  3. Lets any currently-executing iteration finish; abort doesn't kill mid-run work.

A common surprise: aborting a job that's currently executing doesn't stop the execution. The current iteration runs to completion. The abort only prevents future iterations. If you need to halt an in-progress job, you usually have to wait or use the Apex Jobs page in Setup to issue a stronger cancel.

The Schedulable interface contract

Any class you schedule must implement Schedulable:

public class ReconciliationJob implements Schedulable {
    public void execute(SchedulableContext sc) {
        // Your logic. Often delegates to a Database.executeBatch call.
        Database.executeBatch(new ReconciliationBatch(), 200);
    }
}

The execute method runs on the platform's schedule, in a fresh transaction with its own governor budget. The SchedulableContext parameter carries the job id of the current execution, which is useful for logging.

If your schedulable's execute body throws an unhandled exception, the job transitions to ERROR state. Future fire times are skipped. The job is still scheduled (still counts toward the 100-cap, still occupies its name), but it does nothing. Wrap the execute body in try/catch and log the error to a custom object so you notice when a job goes dark.

A pattern that's worth stealing

Adopt a single SchedulableRegistry class in your org that owns the canonical list of scheduled jobs and their cron expressions. Every deploy calls the registry's apply method. Adding a new job means adding one row to the registry, redeploying, and the schedule lands automatically. Removing a job means deleting the row; the apply method aborts it on the next deploy.

The pattern produces three concrete benefits:

  • New developers see the entire scheduled-job footprint of the org in one file.
  • The deploy pipeline can't drift out of sync with the actual scheduled state of the org.
  • Auditing for the 100-cap is a glance at one file, not a SOQL hunt.

The registry pattern works for any platform that creates persistent state on deploy: scheduled jobs, custom metadata records, queueable bootstraps. It's a cheap engineering investment that pays off every time the team changes hands.

Further reading from Salesforce

Related dictionary terms

Share this fix

Share on LinkedInShare on X

Related Apex errors