SfSkills Domains Docs GitHub

Home / apex / apex-scheduled-jobs

apex-scheduled-jobs

Scheduling Apex classes using the Schedulable interface: implementing execute(), cron expressions, System.schedule(), monitoring CronTrigger records, job limits, and job chaining patterns.

Open the skill folder on GitHub


Apex Scheduled Jobs

Use this skill when designing, implementing, reviewing, or troubleshooting Apex classes that run on a time-based schedule using the Schedulable interface. The skill covers the full lifecycle: authoring, scheduling via cron expression, monitoring via CronTrigger, aborting, rescheduling, testing, and deployment considerations.


Before Starting

Gather this context before working in this domain:


Questions to Ask Before Configuring

Ask these before writing the class. Each maps to a documented platform behaviour that a plausible-looking Schedulable gets wrong; the gotcha each one defends against is named in the last column.

AskWhy it mattersWhat a good answer addsDefends
"How long does one run take today, and what happens if run N+1 starts while run N is still going?"CronTrigger.State = 'BLOCKED' guards the scheduler re-entering itself, not the batch it dispatchedThe in-flight guard query, and a decision to skip vs. queueGotcha 11
"If a night is missed entirely — release weekend, maintenance window — must it be made up?"Resuming a paused job "immediately runs one time"; missed executions never runA window-based, idempotent workload instead of a per-fire one, or an explicit high-water markGotcha 10
"Does the work call an external system, and from which class?"Synchronous callouts are unsupported from scheduled Apex, but are supported from a dispatched Batch or Queueable carrying Database.AllowsCalloutsThe marker interface's correct home — one hop down, not on the SchedulableGotcha 3
"Which identity runs the reschedule script, and in which time zone?"The CRON expression is read in the scheduling user's time zone; the job body runs as system with no user permissions appliedA named service identity in the runbook, and explicit CRUD/FLS in the workerGotcha 12
"How many scheduled Apex jobs does this org already have, and what edition is it?"The ceiling is 100 concurrent scheduled Apex classes — 5 in Developer Edition — and it counts JobType = '7' onlyA pre-flight count, and a consolidation plan if the answer is near the ceilingGotcha 2
"What does the class reference, and who else deploys those classes?"An active schedule locks the class and every class it references against updateThe unschedule step sequenced into the release plan, not discovered at deploy timeGotcha 9
"Does anything need to be computed per-run — a date, a record scope, a cutoff?"Member variables are serialized at System.schedule() time and persist into every future runA transient declaration, or the value derived inside execute() instead of in the constructorGotcha 8

What a proper configuration adds over just writing a Schedulable and calling System.schedule: the job is re-runnable after a failed release, it refuses to stack on top of itself, its schedule survives a deploy of any class in its dependency closure, and a single SOQL query tells an operator whether last night's work succeeded rather than only that the timer fired.


Core Concepts

The Schedulable Interface

A class becomes schedulable by implementing the Schedulable interface and defining a single execute(SchedulableContext sc) method. The Apex Developer Guide requires only that "The implemented method must be declared as global or public" (apexdev L16620) — public is the correct default, and the guide's own sample is public with sharing class ScheduledMerge implements Schedulable (apexdev L16628). Reserve global for classes that must be visible across a managed-package namespace boundary. Always declare a sharing mode explicitly, because the body runs as system with no user permissions applied.

public with sharing class MyScheduledJob implements Schedulable {
    public void execute(SchedulableContext sc) {
        // Dispatch work here — avoid heavy logic inline
        Database.executeBatch(new MyWorkBatch(), 200);
    }
}

execute() runs under synchronous governor limits — 100 SOQL, 6 MB heap, 10,000 ms CPU — because "Although scheduled Apex is an asynchronous feature, synchronous limits apply to scheduled Apex jobs" (apexdev L19536). That single sentence is the whole justification for the dispatcher pattern below.

SchedulableContext.getTriggerId() returns the CronTrigger Id for the running job. It is a valid argument to System.abortJob — the reference lists it among the methods returning an abortable job ID (apexrefguide L238688–L238695) — so a one-shot job can retire its own schedule. Aborting does not stop the current run: "any code that is in progress will continue to execute until it completes" (apexrefguide L238663).

Cron Expressions

Salesforce uses six required fields plus an optional year (apexdev L16815-L16817) — so a legal expression has 6 or 7 fields, never the 5 of Unix cron:

Seconds  Minutes  Hours  Day_of_month  Month  Day_of_week  [Year]

Field ranges and legal special characters, from the Apex Developer Guide's own table (apexdev L16826–L16866):

FieldValuesSpecial characters
Seconds0–59none
Minutes0–59none
Hours0–23, - * /
Day_of_month1–31, - * ? / L W
Month1–12 or JAN–DEC, - * /
Day_of_week1–7 or SUN–SAT, - * ? / L #
optional_yearnull or 1970–2099, - * /

Key rules, and where each actually comes from:

Common patterns:

ScheduleCron ExpressionSource
Every day at 1 PM'0 0 13 * * ?'guide example, apexdev L16919
Every hour at 5 past'0 5 * * * ?'guide example, apexdev L16921 — the maximum legal frequency
Last Friday of every month, 10 PM'0 0 22 ? * 6L'guide example, apexdev L16925
Monday–Friday at 10 AM'0 0 10 ? * MON-FRI'guide example, apexdev L16927
Every day at 8 PM during 2010 only'0 0 20 * * ? 2010'guide example, apexdev L16929
Daily at 2:00 AM'0 0 2 * * ?'derived from the field table
First day of each month at midnight'0 0 0 1 * ?'derived; Day_of_week is ?
Every fifth day of the month'0 0 2 1/5 * ?'derived from the / definition, apexdev L16887–L16891

Schedule during off-peak hours: the platform reserves the right to slip the start — "Salesforce schedules the class for execution at the specified time. Actual execution can be delayed based on service availability" (apexdev L16594–L16595) — and contention makes that slip larger.

System.schedule() and the CronTrigger Object

A scheduled job is created with:

String jobId = System.schedule('Job Display Name', cronExpression, new MyScheduledJob());

Monitor active jobs with:

SELECT Id, CronJobDetail.Name, CronExpression, State, NextFireTime, PreviousFireTime
FROM CronTrigger
WHERE State = 'WAITING'
ORDER BY NextFireTime ASC

CronTrigger.State values, from the Object Reference (CronTrigger State): WAITING (awaiting execution), ACQUIRED (picked up, about to execute), EXECUTING, COMPLETE (fired and not scheduled again), ERROR (the trigger definition is broken), DELETED, PAUSED (set by the platform during patch and major releases, then restored automatically), BLOCKED (a second instance was attempted while one was running), PAUSED_BLOCKED (paused by a release while an instance was running).

CronJobDetail.JobType distinguishes what kind of job a CronTrigger row represents: '7' scheduled Apex, '6' scheduled Flow, '8' report run, '3' dashboard refresh, '4' reporting snapshot, '9' batch job, '1' data export, 'A' reporting notification (object_reference, CronJobDetail). Filter on '7' or the count is wrong.

Abort a scheduled job with:

System.abortJob('CronTrigger_Id_Here');

Mode Selection

This skill operates in three modes based on the practitioner's need:


Common Patterns

Schedulable as Dispatcher (Preferred Pattern)

When to use: Any case where the scheduled job processes records, performs DML, or does anything beyond trivial computation. This is the default pattern.

How it works:

  1. The Schedulable class holds minimal state — typically a constructor parameter for scoping (e.g., record type, org-specific flag).
  2. execute() instantiates and dispatches a Batch Apex class or enqueues a Queueable. It does not perform DML or SOQL at scale itself.
  3. The Batch or Queueable carries all governor-intensive work within its own transaction limits.
public with sharing class NightlyLeadCleanupScheduler implements Schedulable {
    public void execute(SchedulableContext sc) {
        Database.executeBatch(new LeadCleanupBatch(), 200);
    }
}

The guide gives the same shape and the same advice: "Though it's possible to do additional processing in the execute method, we recommend that all processing must take place in a separate class" (apexdev L16972–L16973). See references/code-examples.md §1 for the production version, which adds the in-flight guard and structured logging.

Why not the alternative: inline work spends synchronous limits — 100 SOQL, 6 MB heap, 10,000 ms CPU (apexdev L19536, L19544, L19577, L19579), which is less headroom than the Batch or Queueable you were avoiding writing. The dispatched job opens a fresh transaction and gets its own budget, so the dispatcher's footprint stays flat at any data volume.

Self-Identifying Job for Conditional Logic

When to use: A single Schedulable class is reused for multiple schedules with different parameters, and the job needs to know which schedule triggered it.

How it works:

  1. The SchedulableContext.getTriggerId() returns the CronTrigger.Id.
  2. Query CronJobDetail from CronTrigger to retrieve the job name and branch logic accordingly.
public with sharing class MultiPurposeScheduler implements Schedulable {
    public void execute(SchedulableContext SC) {
        CronTrigger ct = [
            SELECT CronJobDetail.Name
            FROM CronTrigger
            WHERE Id = :SC.getTriggerId()
        ];
        if (ct.CronJobDetail.Name.contains('Nightly')) {
            Database.executeBatch(new NightlyBatch(), 200);
        } else {
            System.enqueueJob(new WeeklyReportQueueable());
        }
    }
}

Why not the alternative: Creating one Schedulable class per variation leads to class sprawl. A parameterized or self-identifying class reduces maintenance surface.

Abort-and-Reschedule for Schedule Changes

When to use: The cron schedule for a live job needs to change. There is no in-place modification API.

How it works:

  1. Query CronTrigger to find the job ID by name.
  2. Call System.abortJob(jobId).
  3. Call System.schedule(sameName, newCronExpression, new MyScheduledJob()).
// Run from Anonymous Apex or a deployment script
List<CronTrigger> jobs = [
    SELECT Id FROM CronTrigger
    WHERE CronJobDetail.Name = 'Nightly Lead Cleanup'
    AND State = 'WAITING'
];
if (!jobs.isEmpty()) {
    System.abortJob(jobs[0].Id);
}
System.schedule('Nightly Lead Cleanup', '0 0 3 * * ?', new NightlyLeadCleanupScheduler());

Why not the alternative: Attempting to update CronExpression directly on the CronTrigger record via DML is not supported. The only path to change a schedule is abort and reschedule.


Decision Guidance

SituationRecommended ApproachReason
Need to run Apex on a fixed time-based scheduleSchedulable + System.schedule()Purpose-built platform feature for time-based Apex
Job processes large record volumesSchedulable dispatches Batch ApexBatch provides chunked limits and automatic retry
Job needs to make outbound calloutsSchedulable dispatches a Queueable or a Batch class carrying Database.AllowsCalloutsSynchronous callouts are unsupported from scheduled Apex, but supported from either dispatched class (apexdev L16975–L16977)
Job schedule needs to change post-deploymentAbort existing job, schedule new one with updated cronNo in-place schedule modification API exists
Org is near the ceiling (100, or 5 in Developer Edition)Consolidate into a master scheduler classSynchronous limits apply, so one execute() may add up to 50 jobs with System.enqueueJob (apexdev L19571)
Job runs in multiple sandboxes or prodPlan post-deployment Anonymous Apex scriptScheduled jobs are not Metadata API-deployable
Need sub-hourly firingRe-evaluate: Platform Events, CDC, or a record-triggered path"Apex doesn't allow for a job to be scheduled more than once an hour" (apexdev L16923)
Need one delayed run, not a recurrenceSystem.scheduleBatch(batchable, name, minutesFromNow)Runs once at a future time and needs no Schedulable implementation (apexdev L17293–L17296)
Previous run may still be executing when the next firesGuard in execute() on in-flight AsyncApexJob rowsCronTrigger BLOCKED guards the scheduler's re-entry, not the dispatched worker
Job errors silently and stopsQuery CronTrigger.State = 'ERROR', check AsyncApexJobState field and job history show failure details

  1. Confirm a schedule is the right trigger at all. Read standards/decision-trees/async-selection.md — Q9 and the "Clock (cron)" branch — before writing anything. A schedule that exists only to poll for changed records is usually a record-triggered Flow or a Platform Event; a schedule that exists to run at a fixed clock time is this skill. If the work is purely declarative, flow/scheduled-flows and admin/scheduled-path-patterns own it and this skill does not apply.
  2. Answer the seven questions above, then run the pre-flight count. SELECT COUNT() FROM CronTrigger WHERE CronJobDetail.JobType = '7' AND State IN ('WAITING','ACQUIRED','EXECUTING','PAUSED','PAUSED_BLOCKED','BLOCKED'). Record the number against 100 (5 in Developer Edition) in the release notes; if it is above 80, plan the consolidation before writing the class.
  3. Write the dispatcher and its worker from references/code-examples.md. Copy NightlyRollupScheduler (thin execute(), transient per-run fields, in-flight guard against AsyncApexJob) and SchedulerAdmin (scheduleOrReplace, abortByName, scheduledApexCount). Reference templates/apex/ApplicationLogger.cls for logging and templates/apex/SecurityUtils.cls for CRUD/FLS in the worker — the body runs as system. Hand the worker's design to apex/batch-apex-patterns or apex/apex-queueable-patterns.
  4. Author and validate the CRON expression against the field table in Core Concepts. Seven fields, ? in exactly one day field, no more than once an hour. Confirm the time zone the script will run under, not the one you are sitting in.
  5. Write the test class from references/code-examples.md §4. System.schedule inside Test.startTest()/Test.stopTest(); assert CronExpression, State, TimesTriggered == 0, and NextFireTime before stopTest, and the dispatched AsyncApexJob after it. Use a far-future CRON year and a unique job name per method.
  6. Run the checker, then the tests. python3 skills/apex/apex-scheduled-jobs/scripts/check_apex_scheduled_jobs.py --manifest-dir force-app/main/default/classes --strict, then sf apex run test --tests <YourSchedulerTest>. The checker flags callouts in execute(), malformed CRON literals, unguarded System.schedule, and System.schedule outside the test bracket.
  7. Execute the runbook in order: unschedule → deploy → reschedule → verify. references/code-examples.md §6–§8. Both Anonymous Apex files belong in source control next to the classes; the verification SOQL over CronTrigger and AsyncApexJob is the acceptance step, because a green CronTrigger over a failed batch is the domain's characteristic false-clean signal.

Review Checklist

Run through these before marking scheduled job work complete:


Salesforce-Specific Gotchas

Full write-ups, with guide line cites, are in references/gotchas.md (13 entries). The five that most often survive review:

  1. The active job is data, not metadata. package.xml deploys the class; nothing in it recreates the CronTrigger. Every release touching a schedule needs a post-deploy script (references/code-examples.md §7).
  2. The ceiling counts scheduled Apex only. CronJobDetail.JobType = '7'; Developer Edition stops at 5, not 100.
  3. Synchronous limits, not asynchronous ones. Half the SOQL, half the heap, a sixth of the CPU you would get in a Batch execute().
  4. The schedule locks the class and its references against deployment until the job is aborted.
  5. State is sticky. Constructor-captured values persist into every future run unless declared transient.

Output Artifacts

ArtifactDescription
Schedulable class implementationCorrectly structured class with global modifier and execute(SchedulableContext SC)
Cron expressionValidated expression matching the target schedule with Day_of_month / Day_of_week handling
System.schedule() callReady-to-run statement for Anonymous Apex or post-deployment script
CronTrigger monitoring querySOQL to surface active jobs, state, and next fire time
Abort-and-reschedule scriptAnonymous Apex pattern to change an existing job's schedule
Test class scaffoldUnit test with Test.startTest() / Test.stopTest() and CronTrigger assertion

Reference Files

FileRead it when
references/code-examples.mdYou are building or changing a scheduled job — the complete deployable unit: NightlyRollupScheduler, AccountRollupBatch, SchedulerAdmin, the test class, -meta.xml, package.xml, deploy order, the unschedule → reschedule runbook, and the verification SOQL
references/gotchas.mdSomething behaves unexpectedly — 13 documented platform behaviours with Apex Developer Guide / Object Reference line cites, including the deploy lock, state persistence, BLOCKED semantics, paused-job resume, and time-zone binding
references/examples.mdYou want worked scenarios end to end — nightly cleanup dispatched to Batch, the abort-and-reschedule change, master-scheduler consolidation, and the inline-logic anti-pattern
references/llm-anti-patterns.mdYou are reviewing AI-generated Schedulable code, or self-checking your own output before proposing it
references/well-architected.mdYou need the Reliability / Operational Excellence framing, the architectural trade-offs, or the official source list with the claim each supports
templates/apex-scheduled-jobs-template.mdYou are running the skill as a structured engagement — context capture, CRON builder, review record
scripts/check_apex_scheduled_jobs.pyBefore deploying — static checks over .cls files for callouts in execute(), malformed CRON literals, unguarded System.schedule, and untested schedule calls

scripts/check_apex_scheduled_jobs.py is the canonical checker; scripts/check_apex_scheduled.py alongside it is a backward-compatible alias that delegates every argument to the canonical script and returns the same exit code — invoke either name, but cite the canonical one in new plans and tests.


Package files