Table of Contents
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.
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.
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.
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.
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-FRImeans Monday through Friday),— List of values (e.g.,MON,WED,FRI)/— Increment (e.g.,0/15in minutes means every 15 minutes starting at 0)L— Last (e.g.,Lin Day-of-Month means last day of the month)W— Nearest weekday to given day (e.g.,15Wmeans nearest weekday to the 15th)#— Nth occurrence (e.g.,2#3means 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 |
0 0/1 * * * ?, the platform will reject it. For sub-hourly recurring work, chain Queueable jobs from each other instead.// 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):
// 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
);
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.
// 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:
// 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 |
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
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:
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:
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;
}
}
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:
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.
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.
// 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() { /* ... */ }
}
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.
@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.
@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');
}
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().
@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.
Implement a Schedulable Job
Write a complete Schedulable class that processes overdue records and passes all test assertions.
Solve challenge →Decode and Build Cron Strings
Given a scheduling requirement in plain English, write the correct Salesforce 6-field cron expression.
Solve challenge →Write the Test Class
Given a Schedulable class, write a complete Apex test with proper startTest/stopTest coverage achieving 100%.
Solve challenge →Schedule a Batch from a Scheduler
Build a Schedulable class that launches a Batch Apex job and verify the chain works end to end.
Solve challenge →Frequently Asked Questions
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."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.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.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.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
Apex Triggers — Complete Guide
Events, context variables, bulkification, handler patterns, recursive guards.
Read tutorial →SOQL & SOSL Tutorial
Queries, relationships, aggregate functions, semi-joins, and best practices.
Read tutorial →Batch Apex Tutorial
Database.Batchable, start/execute/finish, chaining, and scheduling batches.
Read tutorial →Salesforce Apex Interview Questions
50+ frequently asked Salesforce developer interview questions and answers.
Read guide →