You have exceeded the maximum number of scheduled Apex jobs (100)
Salesforce caps each org at 100 scheduled Apex jobs in the queue at a time. Once you hit 100, scheduling a new job fails. Audit `CronTrigger` for stale jobs; abort or consolidate before scheduling more.
Also seen asmaximum number of scheduled Apex jobs·100 scheduled jobs·schedule limit·ScheduleException
Your post-deploy script tries to schedule a new nightly cleanup job. The CLI returns You have exceeded the maximum number of scheduled Apex jobs (100). The deploy fails at the scheduling step. The org has been around for three years, has six different teams shipping features, and somewhere along the way the scheduled-job count crossed the platform's ceiling.
The cap and what it includes
Salesforce caps each org at 100 active scheduled Apex jobs at a time. The cap counts every job in any of these states:
WAITING(queued, fires at the next cron time).ACQUIRED(platform has reserved the next execution slot).EXECUTING(currently running).ERROR(last run failed; the schedule still exists).
Jobs in COMPLETED state (one-shot batches that already ran) and DELETED state (aborted) do not count.
The cap is per-org, not per-namespace and not per-user. Every team's scheduled jobs draw from the same 100-slot budget. A heavily-automated org with many teams can hit the limit organically without any single team realizing they were the one to push it over.
The classic broken example
A deploy script that schedules a new job every release without aborting the previous one:
public class PostDeploy {
public static void scheduleCleanup() {
String name = 'Cleanup_' + System.now().getTime();
System.schedule(name, '0 0 3 * * ?', new CleanupJob());
}
}
Each deploy adds a new schedule. The unique timestamp suffix avoids the "already scheduled" error, but it accumulates: ten deploys produce ten scheduled jobs. Over time the count creeps toward 100. The first deploy that crosses 100 fails with this error.
The fix: audit and clean up
The first step is to see what's in the queue. Run this SOQL in the Developer Console:
SELECT
Id,
CronJobDetail.Name,
CronExpression,
NextFireTime,
State,
TimesTriggered,
CreatedBy.Name,
OwnerId
FROM CronTrigger
WHERE CronJobDetail.JobType = '7'
ORDER BY CreatedDate DESC
JobType = '7' filters to scheduled Apex jobs specifically (other types include Reporting Snapshots, Dashboard refreshes, and others that don't count toward the same cap).
The output shows every active scheduled job, the cron expression, when it last triggered, and who created it. Walk the list and categorize:
- Stale jobs from past releases: schedules that the team forgot to clean up. Abort.
- Duplicate jobs: the same logical work scheduled under multiple names. Pick one, abort the rest.
- Error-state jobs: jobs that fail every time they run. Abort or fix the underlying Apex.
- Active jobs that someone owns: leave alone.
To abort a job:
System.abortJob('08e1U000000XXXXAAB');
For bulk cleanup, build the list of stale ids and loop:
List<CronTrigger> stale = [
SELECT Id
FROM CronTrigger
WHERE CronJobDetail.JobType = '7'
AND CronJobDetail.Name LIKE 'Cleanup_%'
AND State = 'WAITING'
];
for (CronTrigger ct : stale) {
System.abortJob(ct.Id);
}
Run this from Anonymous Apex. The cleanup is irreversible (aborted jobs can be re-scheduled but their previous run history stays in CronTrigger.Last_Run until the row is purged), so be deliberate.
The fixed example
A deploy script that respects the cap by aborting its own previous schedules before creating new ones:
public class PostDeploy {
private static final String JOB_PREFIX = 'Cleanup';
private static final String CRON_EXPR = '0 0 3 * * ?';
public static void scheduleCleanup() {
// Abort any prior versions of this job.
for (CronTrigger ct : [
SELECT Id FROM CronTrigger
WHERE CronJobDetail.Name LIKE :(JOB_PREFIX + '%')
AND CronJobDetail.JobType = '7'
]) {
System.abortJob(ct.Id);
}
// Schedule fresh with a stable name.
System.schedule(JOB_PREFIX + '_v1', CRON_EXPR, new CleanupJob());
}
}
Two changes: the schedule name is stable (no timestamp suffix), and the script aborts existing jobs of the same family before scheduling. The deploy is now idempotent. Running it twenty times produces the same final state as running it once.
Consolidation strategies
If your org is approaching the 100 cap and you can't trim further by aborting stale jobs, consider consolidation.
Merge related schedules. Five jobs that each run nightly with different cron expressions might be combinable into one job that dispatches to the five workloads sequentially. The single job uses one slot; the runtime work is the same.
Use Queueable Apex with chaining instead of scheduled jobs. Queueables don't count toward the 100-scheduled-job cap (they have their own flex-queue cap of 50, but that's a different governor). A job that runs every 30 minutes via "schedule the next run from inside the current run" can use a single Schedulable as a heartbeat and Queueables for the actual work.
Adopt Batch Apex with scheduled triggers. Batch jobs scheduled via System.scheduleBatch use the same cap, but they consolidate naturally: one Batch Apex class can run on hundreds of millions of records and only occupies one slot. Five lightweight schedules can become one batch.
Architectural patterns that scale
Mature orgs adopt one of two patterns to keep the scheduled-job count low:
Pattern A: a single dispatcher. One Schedulable class runs every minute, hour, or whatever cadence. Inside execute(), it reads a metadata table that defines which sub-jobs should fire now and dispatches each one to a Queueable. The dispatcher uses one slot. Adding a new sub-job means adding a metadata row, not creating a new schedule.
Pattern B: feature-team scheduling registry. Each team has one canonical Schedulable that lives in its own namespace. The team's deploys update the registry's metadata to define which work runs when. The team uses one slot regardless of how many internal jobs they manage. Cross-team coordination is the registry config, not the scheduler.
Either pattern keeps the scheduled-job count predictable and low. The trade-off is the upfront investment in the dispatcher logic. For orgs with multiple teams shipping concurrently, the investment pays off within a few quarters.
Monitoring proactively
Set up a dashboard that surfaces the scheduled-job count. A simple report:
SELECT COUNT()
FROM CronTrigger
WHERE CronJobDetail.JobType = '7'
AND State IN ('WAITING', 'ACQUIRED', 'EXECUTING', 'ERROR')
Threshold the result at 80. If the count exceeds 80, alert the team. The 20-job buffer gives time to audit and clean up before the cap halts a deploy.
For continuous monitoring, schedule a daily Apex job that emits the count to a custom logging object. Build a Lightning dashboard over the log to show the trend. A scheduled-job count creeping upward over six months is a sign that some team's schedule hygiene is slipping.
The interaction with installs and managed packages
Installing a managed package can add scheduled jobs to your org's count. Some packages register schedules during install for ongoing maintenance work. The added jobs come out of your org's 100-slot budget; the package vendor doesn't get extra slots.
Before installing a heavy package in a near-cap org, check the package's documentation for "scheduled jobs added" and verify you have headroom. If not, audit and trim before installing, or move some of your own jobs into a managed format that doesn't compete for the slots.
When a scheduled job fails
A job in ERROR state still counts toward the cap. The state means "the last execution failed; the schedule itself is intact." Future fire times are skipped until the failure is addressed (often by aborting and re-scheduling after a code fix).
A common surprise: a single bad release that breaks a scheduled job leaves a permanent ERROR row in CronTrigger. The row never auto-cleans. Six months later, the team doesn't remember the schedule exists, but it's still taking up a slot.
The audit query above catches these. Filter to State = 'ERROR' and abort everything older than a quarter that nobody recognizes.
A note on the cap's evolution
The 100-job cap has been stable for many years. Salesforce occasionally hints at raising it in release notes, but the official limit remains 100 at the time of writing. Don't design around an assumed future increase. Treat 100 as the hard ceiling and architect accordingly.
For very large orgs with platform exceptions, an Account Executive can sometimes negotiate a higher limit, but this is rare. The right path for almost all teams is consolidation, not negotiation.
Naming conventions that age well
A small but high-payoff habit: include the owning team and the purpose in every scheduled job name. Instead of Cleanup_v3, use PaymentsTeam_StaleAuthTokenCleanup. When you audit at quarter end, you can tell at a glance who owns each job and what it does without opening the source.
The convention also helps when the original author leaves. The job name itself documents enough that the next maintainer can decide whether to keep or abort without archaeology.
For consistency, agree on a prefix per team. The prefix becomes a natural filter in audit queries:
SELECT COUNT(Id), CronJobDetail.Name FROM CronTrigger
WHERE CronJobDetail.Name LIKE 'PaymentsTeam_%'
GROUP BY CronJobDetail.Name
The output groups every Payments-team schedule and shows the count. Easy to spot duplicates or stragglers.
How the cap interacts with Permission Sets that grant Manage Scheduled Apex
The ManageScheduledApex user permission lets a user view and abort schedules belonging to other users. By default, ordinary users only see their own schedules in Setup → Scheduled Jobs. Without ManageScheduledApex, an audit by a junior admin shows an incomplete picture.
If you're auditing the org-wide schedule count, run the SOQL above (it bypasses the per-user filtering) and ensure you're authenticated as a user with ManageScheduledApex. The Apex query reads CronTrigger from a service-role perspective when run as System Administrator.
When test methods inflate the count
Apex test methods that call System.schedule create temporary CronTrigger rows. The platform rolls them back when the test transaction completes, but if a test partially fails mid-schedule, the rollback may leave residue. Over time, residue can accumulate.
The diagnostic: filter the audit query to CreatedDate >= LAST_N_DAYS:7 and look for jobs created by test runs. If you see test-created schedules that should have been rolled back, the test framework is missing a Test.startTest() boundary somewhere. Fix the test, then abort the orphaned rows.
Further reading from Salesforce
Related dictionary terms
Share this fix
Related Apex errors
Initial term of field expression must be a concrete SObject: <type>
ApexYou wrote `someThing.Field__c` where `someThing` isn't a specific SObject type — usually an `SObject` or `Object` reference. Apex needs the …
Method does not exist or incorrect signature
ApexThe Apex compiler can't find a method matching the call you wrote — wrong name, wrong argument types, or wrong number of arguments. The comp…
MIXED_DML_OPERATION: DML operation on setup object is not allowed after you have updated a non-setup object (or vice versa)
ApexSalesforce splits objects into "setup" (User, Group, GroupMember, PermissionSet, Profile, Queue, etc.) and "non-setup" (everything else). A …
System.AsyncException: Future method cannot be called from a future or batch method
ApexYou called an `@future` method from inside another `@future`, batch, or queueable. Salesforce blocks recursive async chains because they cou…
System.CalloutException: Read timed out
ApexThe HTTP callout exceeded its allowed read time waiting for the remote server's response. Salesforce caps a single callout at 120 seconds (d…