Schedulable Apex Tutorial

Salesforce Schedulable Apex —
Complete Tutorial & Examples

Everything you need to write, schedule, and manage Schedulable Apex jobs: the Schedulable interface, cron expressions, programmatic scheduling, governor limits, and common pitfalls.

🕐 ~14 min read 💻 Async Apex 👥 All experience levels
Advertisement

1 What Is Schedulable Apex?

Schedulable Apex is one of the four asynchronous Apex execution patterns in Salesforce (alongside Future methods, Queueable Apex, and Batch Apex). It allows you to tell the Salesforce platform to execute an Apex class at a specific time in the future, or repeatedly on a defined recurring schedule — similar to a cron job on a Linux server.

Common use cases include:

  • Nightly data cleanup or archival (deleting old log records, archiving closed cases older than a year)
  • Scheduled report generation or data aggregation (rolling up weekly revenue totals into a custom object)
  • Periodic data sync with external systems (triggering a Queueable that calls an external REST API)
  • Automated reminder or follow-up notifications (sending emails to leads who haven't been contacted in 30 days)
  • Triggering Batch Apex on a schedule (running a large data migration every Sunday at 2 AM)

Scheduled jobs run in their own transaction and receive a fresh set of governor limits, just like any other asynchronous context. The job is not guaranteed to run at the exact second specified — Salesforce queues the job and runs it as close to the scheduled time as system load allows, typically within a few minutes.

Async vs Sync: Schedulable Apex runs asynchronously. Your users never wait for it, and it does not share governor limits with user-initiated transactions. Use it for work that is time-based, not user-triggered.

Schedulable Apex vs Other Async Patterns

Pattern Trigger Best For Max Queued
Schedulable Apex Time / cron schedule Recurring, time-based tasks 100 active jobs
Batch Apex Programmatic or Schedulable Large data volumes (>50k records) 100 queued or active
Queueable Apex Programmatic (System.enqueueJob) Chained async work, callouts 50 per transaction
Future Methods Programmatic (@future) Simple callouts, mixed DML 50 per transaction

2 The Schedulable Interface & execute() Method

To make an Apex class schedulable, you must implement the Schedulable interface. This interface declares exactly one method that your class must provide: execute(SchedulableContext sc). The platform calls this method when the scheduled time arrives.

Apex — Minimal Schedulable ClassCorrect Pattern
global class LeadCleanupScheduler implements Schedulable {

    global void execute(SchedulableContext sc) {
        // Your business logic goes here
        // This block runs at the scheduled time
        List<Lead> staleLeads = [
            SELECT Id FROM Lead
            WHERE Status = 'Open - Not Contacted'
            AND   CreatedDate < LAST_N_DAYS:90
            AND   IsConverted = false
        ];
        if (!staleLeads.isEmpty()) {
            for (Lead l : staleLeads) {
                l.Status = 'Closed - Not Converted';
            }
            update staleLeads;
        }
    }
}

The SchedulableContext Interface

The SchedulableContext object passed into execute() gives you access to the job's metadata at runtime. Its single method is getTriggerId(), which returns the ID of the CronTrigger record that represents this scheduled job. You can use this ID to query job details or to abort the job programmatically from within the execute method itself.

Apex — Using SchedulableContext
global void execute(SchedulableContext sc) {
    // Retrieve the CronTrigger ID for this job
    Id jobId = sc.getTriggerId();

    // Query job metadata — useful for logging
    CronTrigger ct = [
        SELECT Id, CronJobDetail.Name, NextFireTime, TimesTriggered
        FROM  CronTrigger
        WHERE Id = :jobId
    ];
    System.debug('Job name: ' + ct.CronJobDetail.Name);
    System.debug('Next fire: ' + ct.NextFireTime);
    System.debug('Times run: ' + ct.TimesTriggered);

    // Business logic
    doWork();
}

private void doWork() {
    // ... actual processing
}

Access Modifiers: global vs public

The class and its execute method must be declared global if the class lives in a managed package and will be scheduled from outside the package. For org-level (unmanaged) code, public is sufficient. Most developers use global by convention for schedulable classes since it is always compatible with both managed and unmanaged deployment.

No-Argument Constructor Required: If you define any constructor with parameters in your schedulable class, you must also explicitly define a no-argument constructor. The scheduler platform instantiates your class using a no-arg constructor. Missing it will cause a runtime error when the job fires.
Apex — Constructor PatternBest Practice
global class OpportunityRollupScheduler implements Schedulable {

    private String targetAccountId;

    // Required no-arg constructor
    global OpportunityRollupScheduler() {}

    // Optional parameterised constructor for programmatic scheduling
    global OpportunityRollupScheduler(String accountId) {
        this.targetAccountId = accountId;
    }

    global void execute(SchedulableContext sc) {
        if (String.isNotBlank(targetAccountId)) {
            rollupForAccount(targetAccountId);
        } else {
            rollupAll();
        }
    }

    private void rollupForAccount(String accId) { /* ... */ }
    private void rollupAll() { /* ... */ }
}

3 Cron Expressions in Salesforce

Salesforce uses a cron expression string to define when a scheduled job should fire. Unlike standard Unix cron (5 fields), Salesforce cron expressions have 6 required fields and 1 optional field (7 total), giving you second-level precision.

Cron Expression Field Reference

Position Field Allowed Values Special Characters
1 Seconds 0–59 , - * /
2 Minutes 0–59 , - * /
3 Hours 0–23 , - * /
4 Day of Month 1–31 , - * ? / L W
5 Month 1–12 or JAN–DEC , - * /
6 Day of Week 1–7 or SUN–SAT , - * ? / L #
7 Year (optional) 1970–2099 , - * /

Special Characters Explained

  • * — Every value in that field (e.g., every month, every day)
  • ? — No specific value; used in Day-of-Month or Day-of-Week when the other is specified
  • - — Range (e.g., MON-FRI means Monday through Friday)
  • , — List of values (e.g., MON,WED,FRI)
  • / — Increment (e.g., 0/15 in minutes means every 15 minutes starting at 0)
  • L — Last (e.g., L in Day-of-Month means last day of the month)
  • W — Nearest weekday to given day (e.g., 15W means nearest weekday to the 15th)
  • # — Nth occurrence (e.g., 2#3 means the 3rd Monday of the month)

Common Cron Expression Examples

Expression Meaning
0 0 2 * * ? Every day at 2:00 AM
0 0 6 ? * MON-FRI Every weekday at 6:00 AM
0 0 0 1 * ? First day of every month at midnight
0 0 18 ? * SUN Every Sunday at 6:00 PM
0 30 9 L * ? Last day of every month at 9:30 AM
0 0 12 ? * 2#1 First Monday of every month at noon
0 0 8 1 JAN ? January 1st at 8:00 AM every year
🚫
Minimum Frequency Is Once Per Hour: Salesforce does not permit scheduling a job to run more frequently than once per hour through the standard scheduler. If you attempt to schedule a job every minute using 0 0/1 * * * ?, the platform will reject it. For sub-hourly recurring work, chain Queueable jobs from each other instead.
Apex — Cron Expressions in Code
// Every day at midnight
String dailyMidnight = '0 0 0 * * ?';

// Every Monday at 8 AM
String mondayMorning = '0 0 8 ? * MON';

// First day of every quarter at 1 AM
String quarterly = '0 0 1 1 1,4,7,10 ?';

// Every weekday at 9:30 AM
String weekdayMorning = '0 30 9 ? * MON-FRI';

// Last day of each month at 11:45 PM
String monthEnd = '0 45 23 L * ?';

4 Scheduling Jobs: System.schedule() & the UI

There are two ways to schedule an Apex job in Salesforce: programmatically using System.schedule() in Apex, or declaratively through the Apex Scheduler UI in Setup.

System.schedule() Signature

The System.schedule() method accepts three parameters and returns the job ID (a string representation of the CronTrigger record ID):

Apex — System.schedule() Signature
// Signature
String jobId = System.schedule(
    String jobName,         // Unique name for this scheduled job
    String cronExpression,  // 6 or 7-field cron string
    Object schedulable     // Instance of a class that implements Schedulable
);

// Example usage
LeadCleanupScheduler job = new LeadCleanupScheduler();
String jobId = System.schedule(
    'Nightly Lead Cleanup',
    '0 0 2 * * ?',
    job
);
Job Name Must Be Unique: If you call System.schedule() with a job name that already exists in the org (case-insensitive), it throws a System.AsyncException. Always abort the existing job first, or use a unique timestamped name when re-scheduling from code.

Aborting a Scheduled Job

Use System.abortJob(jobId) to cancel a scheduled job before it fires. You can get the job ID either from the return value of System.schedule() or by querying the CronTrigger object.

Apex — Aborting and Re-scheduling
// Find an existing job by name
List<CronTrigger> existingJobs = [
    SELECT Id
    FROM  CronTrigger
    WHERE CronJobDetail.Name = 'Nightly Lead Cleanup'
    AND   State NOT IN ('DELETED', 'COMPLETE')
];

// Abort existing job if found
for (CronTrigger ct : existingJobs) {
    System.abortJob(ct.Id);
}

// Schedule the updated job
String newJobId = System.schedule(
    'Nightly Lead Cleanup',
    '0 0 3 * * ?',  // moved from 2AM to 3AM
    new LeadCleanupScheduler()
);

Scheduling via the Setup UI

In Setup, navigate to Apex Classes and click Schedule Apex. You can select any class that implements the Schedulable interface, provide a job name, and configure the schedule using a form (weekly, monthly, or custom cron). This is useful for one-time setup of recurring jobs that administrators manage, but does not support passing constructor parameters.

Querying Scheduled Jobs with CronTrigger

The CronTrigger sObject stores all scheduled job metadata. You can query it to monitor your jobs programmatically:

SOQL — Querying CronTrigger
// List all active scheduled Apex jobs
List<CronTrigger> jobs = [
    SELECT
        Id,
        CronJobDetail.Name,
        CronJobDetail.JobType,
        CronExpression,
        State,
        NextFireTime,
        PreviousFireTime,
        TimesTriggered,
        StartTime,
        EndTime
    FROM  CronTrigger
    WHERE CronJobDetail.JobType = '7'  // 7 = Scheduled Apex
    AND   State NOT IN ('DELETED', 'COMPLETE', 'ERROR')
    ORDER BY NextFireTime
];

for (CronTrigger ct : jobs) {
    System.debug(ct.CronJobDetail.Name + ' — next: ' + ct.NextFireTime);
}

CronTrigger State Values

State Meaning Badge
WAITING Job is scheduled and waiting for the next fire time Active
ACQUIRED Job has been picked up by the scheduler and is about to run Running
EXECUTING Job is currently running Running
PAUSED Job is paused (e.g., during a deployment) Paused
COMPLETE Job has fired all occurrences (one-time job done) Done
ERROR Job encountered an unhandled exception Error
DELETED Job was aborted via System.abortJob() Deleted
Advertisement

5 Governor Limits in Scheduled Context

Schedulable Apex runs asynchronously, so it benefits from the same elevated governor limits as other async contexts. However, there are additional org-level constraints specific to the scheduler that you must be aware of.

Key Governor Limits

100
Max concurrent scheduled jobs per org
50,000
SOQL rows retrieved per transaction
150
SOQL queries per transaction
10,000
DML rows per transaction
150
DML statements per transaction
60,000
CPU time (ms) per transaction
🚫
100-Job Org Limit: Salesforce caps the total number of scheduled Apex jobs (including paused jobs) at 100 per org. This is an absolute hard limit — no exceptions. If you hit it, System.schedule() will throw a System.AsyncException. Always abort jobs you no longer need and avoid scheduling temporary one-off jobs from triggers or Apex code running frequently.

What Scheduled Apex Cannot Do Directly

  • HTTP callouts: You cannot make synchronous HTTP callouts inside execute(). Enqueue a Queueable that has callout capability instead.
  • Mixed DML in certain contexts: The standard mixed-DML restriction (sObjects that require setup vs non-setup) still applies.
  • UI-related operations: PageReference redirects, form submissions, and anything tied to a user session are not available.

Handling Large Data Volumes

If your scheduled job needs to process more records than a single transaction allows, launch a Batch Apex job from within execute(). This is the most common and recommended pattern for heavy data processing on a schedule:

Apex — Scheduling a Batch JobRecommended Pattern
global class AccountSyncScheduler implements Schedulable {
    global void execute(SchedulableContext sc) {
        // Launch batch to process all accounts needing sync
        AccountSyncBatch batchJob = new AccountSyncBatch();
        Database.executeBatch(batchJob, 200);
    }
}

// Corresponding Batch class
global class AccountSyncBatch implements Database.Batchable<SObject> {
    global Database.QueryLocator start(Database.BatchableContext bc) {
        return Database.getQueryLocator(
            'SELECT Id, Name FROM Account WHERE Sync_Pending__c = true'
        );
    }
    global void execute(Database.BatchableContext bc, List<Account> scope) {
        // Process each batch of 200 accounts
        for (Account acc : scope) {
            acc.Sync_Pending__c = false;
            acc.Last_Synced__c = Datetime.now();
        }
        update scope;
    }
    global void finish(Database.BatchableContext bc) {}
}

6 Chaining Scheduled Jobs

Sometimes you need a job to fire once in the future from within an existing scheduled job — either to schedule the next iteration dynamically, or to trigger a different job as part of a workflow. You can call System.schedule() from inside execute() to achieve this.

Self-Rescheduling Pattern

This pattern is useful when you want more control over the next execution time than a fixed cron expression allows. For example, you might want to schedule the next run to happen only when certain conditions are met, or push it out further based on system load:

Apex — Self-Rescheduling Job
global class AdaptiveScheduler implements Schedulable {

    private static final String JOB_NAME = 'Adaptive Data Sync';

    global void execute(SchedulableContext sc) {
        // Do the actual work
        Integer recordsProcessed = doWork();

        // Self-reschedule for 2 hours from now
        Datetime nextRun = Datetime.now().addHours(2);
        String cronExpr = ''
            + nextRun.second() + ' '
            + nextRun.minute() + ' '
            + nextRun.hour()   + ' '
            + nextRun.day()    + ' '
            + nextRun.month()  + ' ? '
            + nextRun.year();

        // Abort this job before rescheduling to avoid duplicate names
        System.abortJob(sc.getTriggerId());
        System.schedule(JOB_NAME, cronExpr, new AdaptiveScheduler());
    }

    private Integer doWork() {
        // Business logic here — returns count of processed records
        return 0;
    }
}
Abort Before Re-scheduling: Always call System.abortJob(sc.getTriggerId()) before scheduling a replacement job with the same name. If you forget to abort, the System.schedule() call will throw AsyncException: Schedulable class has jobs pending or in progress. Note that aborting from within execute() does not stop the current execution — it only removes the next scheduled occurrence.

Chaining to a Different Job

You can also kick off a completely separate scheduled job or a Queueable job from within execute(). This is useful for building multi-step async pipelines:

Apex — Chaining to Queueable for Callouts
global class NightlyReportScheduler implements Schedulable {
    global void execute(SchedulableContext sc) {
        // Step 1: aggregate local data (DML-safe in scheduled context)
        aggregateReportData();

        // Step 2: hand off to Queueable for the external API callout
        System.enqueueJob(new SendReportQueueable());
    }

    private void aggregateReportData() {
        // Query + update logic here
    }
}

public class SendReportQueueable implements Queueable, Database.AllowsCallouts {
    public void execute(QueueableContext ctx) {
        // Callout to external reporting API is allowed here
        Http http = new Http();
        HttpRequest req = new HttpRequest();
        req.setEndpoint('https://api.example.com/reports');
        req.setMethod('POST');
        HttpResponse res = http.send(req);
        System.debug('API response: ' + res.getStatusCode());
    }
}

7 Best Practices & Common Pitfalls

Keep execute() Lean

The execute() method should act as an orchestrator, not a monolith. Delegate actual business logic to separate helper classes or trigger Batch/Queueable jobs. This makes the code testable in isolation and avoids tight coupling between scheduling and processing logic.

Defend Against the 100-Job Limit

Always check for and abort existing jobs before scheduling new ones with the same name. Consider wrapping your System.schedule() call in a try-catch to handle the System.AsyncException gracefully when the org limit has been reached.

Apex — Safe Scheduling HelperDefensive Pattern
public static String safeSchedule(
    String jobName,
    String cronExpr,
    Schedulable jobInstance
) {
    // Abort any existing job with the same name
    for (CronTrigger ct : [
        SELECT Id FROM CronTrigger
        WHERE CronJobDetail.Name = :jobName
        AND   State NOT IN ('DELETED', 'COMPLETE')
    ]) {
        System.abortJob(ct.Id);
    }

    try {
        return System.schedule(jobName, cronExpr, jobInstance);
    } catch (System.AsyncException ex) {
        System.debug(LoggingLevel.ERROR,
            'Could not schedule job "' + jobName + '": ' + ex.getMessage());
        return null;
    }
}

Use Custom Metadata for Cron Expressions

Hard-coding cron expressions in Apex means a deployment is required every time the schedule changes. Instead, store cron expressions and job configuration in a Custom Metadata Type. Your scheduled class reads the metadata at runtime, making schedule changes admin-friendly without code deployments.

Apex — Reading Cron from Custom Metadata
// Custom Metadata: Job_Schedule__mdt with field Cron_Expression__c
global class ConfigurableScheduler implements Schedulable {
    global void execute(SchedulableContext sc) {
        Job_Schedule__mdt config = [
            SELECT Cron_Expression__c, Is_Active__c
            FROM  Job_Schedule__mdt
            WHERE DeveloperName = 'LeadCleanupJob'
            LIMIT 1
        ];

        if (!config.Is_Active__c) {
            System.abortJob(sc.getTriggerId());
            return;
        }

        doWork();
    }

    private void doWork() { /* ... */ }
}
Best Practices Summary: Always bulk-query records rather than querying inside loops. Abort old jobs before re-scheduling with the same name. Delegate heavy work to Batch Apex. Store cron expressions in Custom Metadata. Add try-catch around System.schedule() calls in production. Log CronTrigger.TimesTriggered for monitoring. Never schedule from triggers (triggers can fire hundreds of times, each consuming a scheduled job slot).

Do Not Schedule from Triggers

A common mistake is calling System.schedule() inside a trigger. If 200 records are inserted in one DML statement, the trigger fires once in bulk — but if your trigger calls System.schedule() in a loop or even once per invocation across multiple transactions, you will rapidly exhaust the 100-job limit and generate System.AsyncException for legitimate users. Use a trigger handler that calls a scheduled job only from specific, controlled code paths (e.g., a custom button, a named process, or a once-per-day admin action).

8 Testing Schedulable Apex

Testing Schedulable Apex requires a specific pattern because execute() is normally called asynchronously by the platform. In a test context, you use Test.startTest() and Test.stopTest() to bracket the System.schedule() call. When Test.stopTest() is called, Salesforce forces all pending async jobs (including the scheduled one) to run synchronously, so you can assert outcomes in the same test method.

Apex — Basic Schedulable TestRecommended Pattern
@isTest
private class LeadCleanupSchedulerTest {

    @TestSetup
    static void setup() {
        // Create test data BEFORE Test.startTest()
        List<Lead> leads = new List<Lead>();
        for (Integer i = 0; i < 10; i++) {
            leads.add(new Lead(
                FirstName    = 'Test',
                LastName     = 'Lead' + i,
                Company      = 'ACME',
                Status       = 'Open - Not Contacted',
                IsConverted  = false
            ));
        }
        insert leads;
    }

    @isTest
    static void testScheduledExecution() {
        // Verify setup data
        System.assertEquals(10, [SELECT COUNT() FROM Lead]);

        Test.startTest();
            LeadCleanupScheduler job = new LeadCleanupScheduler();
            String jobId = System.schedule(
                'Test Lead Cleanup',
                '0 0 2 * * ?',
                job
            );
            // Verify the job was scheduled
            System.assertNotEquals(null, jobId, 'Job ID should not be null');
        Test.stopTest();
        // After stopTest, execute() has run synchronously

        // Assert outcomes
        Integer closedLeads = [
            SELECT COUNT() FROM Lead
            WHERE Status = 'Closed - Not Converted'
        ];
        System.assertEquals(10, closedLeads, 'All stale leads should be closed');
    }

    @isTest
    static void testCronTriggerCreated() {
        Test.startTest();
            String jobId = System.schedule(
                'Test Cron Check',
                '0 0 8 ? * SUN',
                new LeadCleanupScheduler()
            );
        Test.stopTest();

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

Testing a Schedulable That Calls a Batch Job

When your scheduled class launches a Batch Apex job, you need to ensure the test executes both the scheduled job and the batch within a single Test.startTest()/Test.stopTest() block. Test.stopTest() will force both to run synchronously in order.

Apex — Testing Scheduler + Batch Chain
@isTest
static void testSchedulerLaunchesBatch() {
    // Create test accounts
    List<Account> accs = new List<Account>();
    for (Integer i = 0; i < 5; i++) {
        accs.add(new Account(
            Name           = 'Test Account ' + i,
            Sync_Pending__c = true
        ));
    }
    insert accs;

    Test.startTest();
        System.schedule(
            'Test Account Sync',
            '0 0 1 * * ?',
            new AccountSyncScheduler()
        );
    Test.stopTest(); // Forces scheduler execute() AND batch to run

    Integer synced = [
        SELECT COUNT() FROM Account
        WHERE Sync_Pending__c = false
    ];
    System.assertEquals(5, synced, 'All accounts should be marked synced');
}
Test Coverage Tip: Salesforce requires 75% code coverage for deployment. For Schedulable classes, the coverage counter includes the execute() body. Ensure your test assertions exercise both the happy path (records exist and are processed) and edge cases (empty result sets, unexpected record states) to achieve high coverage and catch regressions early.

Testing Self-Rescheduling Jobs

For jobs that call System.abortJob() and then System.schedule() within execute(), be aware that within a test, the abort and re-schedule both happen synchronously inside Test.stopTest(). Verify the new job was created by querying CronTrigger after Test.stopTest().

Apex — Verifying Re-Schedule in Tests
@isTest
static void testSelfReschedule() {
    Test.startTest();
        System.schedule(
            'Adaptive Data Sync',
            '0 0 6 * * ?',
            new AdaptiveScheduler()
        );
    Test.stopTest();

    // After execute() ran, a new job should have been scheduled
    Integer jobCount = [
        SELECT COUNT() FROM CronTrigger
        WHERE CronJobDetail.Name = 'Adaptive Data Sync'
        AND   State NOT IN ('DELETED', 'COMPLETE')
    ];
    System.assertEquals(1, jobCount, 'Job should have rescheduled itself');
}

Practice Schedulable Apex on ApexArena

Reinforce what you've learned with hands-on coding challenges. ApexArena's browser-based Apex editor runs real Salesforce code — no developer org required.

Frequently Asked Questions

What is Schedulable Apex in Salesforce?
Schedulable Apex is a mechanism that lets you run Apex code at a specified time or on a recurring schedule. You implement the Schedulable interface in your class (which requires a single execute(SchedulableContext sc) method) and then register the job using System.schedule() or via the Apex Scheduler UI in Setup. The job runs asynchronously in its own transaction with its own set of governor limits.
How do you write a cron expression in Salesforce?
Salesforce cron expressions have 7 fields (unlike the standard Unix 5-field cron): Seconds Minutes Hours Day-of-Month Month Day-of-Week Year. For example: "0 0 6 ? * MON-FRI *" runs at 6:00 AM every weekday. The "?" character means "no specific value" — used when you specify either day-of-month or day-of-week but not both. Year is optional. Maximum scheduling frequency is once per hour.
How many Apex scheduled jobs can run simultaneously in Salesforce?
Salesforce allows a maximum of 100 scheduled Apex jobs at one time across your entire org. This includes both user-scheduled jobs and Apex code that schedules jobs. Each call to System.schedule() consumes one slot. You can check current usage in Setup > Apex Jobs. Jobs that have finished or failed are automatically removed from this count.
Can a Scheduled Apex job make callouts to external systems?
Yes, but not directly. The execute() method of a scheduled job cannot itself make HTTP callouts (because synchronous callouts are not allowed in scheduled contexts). Instead, you can enqueue a Queueable job from inside execute() and make the callout from within the Queueable. This pattern is commonly used for nightly data sync operations with external systems.
How do you test Schedulable Apex?
Wrap your System.schedule() call inside Test.startTest() and Test.stopTest(). The Test.stopTest() call forces the scheduled job to run synchronously within the test. Then assert your expected outcomes after Test.stopTest(). Use Test.setMock() if the job makes callouts, and ensure your test data is created before Test.startTest() so it is visible to the scheduled job.

More Salesforce Tutorials