EventBridge Scheduler Cron: Expressions, Retries, and Monitoring
Contents
An EventBridge Scheduler cron expression has six fields, cron(minutes hours day-of-month month day-of-week year), and one of the two day fields must be ?: cron(30 2 * * ? *) runs at 02:30 every day. Day-of-week runs from 1 (Sunday) to 7 (Saturday), the expression is evaluated in UTC unless the schedule names a time zone, and nothing runs more often than once a minute. Scheduler retries a failed call to its target, but a job that fails after Lambda accepts the event is outside its view, and a disabled schedule raises no error at all. Cron expression syntax covers the standard five-field format that AWS extends.
EventBridge Scheduler vs. EventBridge Scheduled Rules#
EventBridge has two ways to run something on a schedule, and both accept the same cron() and rate() syntax. Scheduled rules are, in AWS's words, "a legacy feature of EventBridge", and "We recommend that you use Scheduler to invoke targets on a schedule" (scheduled rules). Features and default quotas, checked October 25, 2026:
| EventBridge Scheduler | Scheduled rule (legacy) | |
|---|---|---|
| Time zone | Any IANA zone in ScheduleExpressionTimezone; UTC by default |
UTC only |
| One-time runs | at() expressions |
No at() |
| Flexible time window | 1 to 1,440 minutes | None |
| Targets | One per schedule: templated targets, or a universal target for "more than 270 AWS services and over 6,000 API operations" | Up to 5 per rule |
| Permissions | An execution role, always | An execution role, or a resource-based policy for Lambda, SNS, and SQS |
| Retries and DLQ | Retry policy and SQS dead-letter queue per schedule | Retry policy (defaults: 24 hours, 185 retries) and SQS dead-letter queue |
| Default quota | 10,000,000 schedules per Region | 300 rules per event bus in most Regions; scheduled rules go only on the default bus |
Sources: Scheduler features, targets, and quotas; rule schedules, permissions, retries, and quotas. The rest of this guide covers Scheduler.
The cron() Expression Format#
| Field | Values | Wildcards |
|---|---|---|
| Minutes | 0-59 | , - * / |
| Hours | 0-23 | , - * / |
| Day-of-month | 1-31 | , - * ? / L W |
| Month | 1-12 or JAN-DEC | , - * / |
| Day-of-week | 1-7 or SUN-SAT | , - * ? L # |
| Year | 1970-2199 | , - * / |
Three rules trip up anyone coming from crontab (schedule types):
- One day field must be
?. You can't restrict both day fields or use*in both: "If you use it in one, you must use ? in the other." - Sunday is 1.
2-6is Monday through Friday, so a crontab's1-5pasted in becomes Sunday through Thursday. Names such asMON-FRImean the same everywhere. - The year is required. Use
*for every year.
In day-of-month, L is the last day and 15W the weekday nearest the 15th. In day-of-week, 6L is the last Friday and 2#1 the first Monday. Expressions that would fire more than once a minute aren't supported (troubleshooting), and a run lands anywhere in its minute: a schedule for 1:00 "will invoke the target API between 1:00:00 and 1:00:59."
| Schedule | Expression |
|---|---|
| Every day at 02:30 | cron(30 2 * * ? *) |
| Every 15 minutes | cron(0/15 * * * ? *) |
| Weekdays at 09:00 | cron(0 9 ? * MON-FRI *) |
| Last day of the month at 23:00 | cron(0 23 L * ? *) |
| Last Friday of the month at 10:15 | cron(15 10 ? * 6L *) |
| First Monday of the month at 09:00 | cron(0 9 ? * 2#1 *) |
| Daily at 12:00, during 2027 only | cron(0 12 * * ? 2027) |
rate() and at() Expressions#
rate(value unit) takes minutes, hours, or days, as in rate(5 minutes). The schedule's StartDate sets the first occurrence; without one, it "starts invoking the target immediately." The interval is fixed, but the clock times depend on when it started. rate(1 days) is 24 hours of real time, so its local time moves an hour at each daylight saving change.
at(2026-11-20T13:00:00) runs once. A completed one-time schedule still counts against the quota, so set ActionAfterCompletion to DELETE (CreateSchedule).
Time Zones and Daylight Saving Time#
Set ScheduleExpressionTimezone to an IANA name such as America/New_York for cron() and at() schedules; without it they run in UTC. Scheduler follows the zone's daylight saving changes: an invocation in the hour the spring change skips "is skipped", and one in the hour the fall change repeats runs once (daylight saving). Cron daylight saving time compares this with other schedulers.
Flexible Time Windows#
Every schedule must set FlexibleTimeWindow. {"Mode": "OFF"} invokes the target in the scheduled minute; {"Mode": "FLEXIBLE", "MaximumWindowInMinutes": 15} invokes it at some point in the 15 minutes after the scheduled time, which "improves the reliability of your schedule by dispersing your target invocations" (flexible time windows). The window can be 1 to 1,440 minutes (FlexibleTimeWindow). It only moves runs later, so anything waiting for the result must allow the scheduled time plus the window plus the run time.
Delivery, Retries, and Dead-Letter Queues#
Scheduler "provides at-least-once event delivery to targets" (features), so one occurrence can reach the target twice. Idempotent scheduled jobs shows how to claim each period before doing its work; putting <aws.scheduler.scheduled-time> in the schedule's Input gives the job that period's key (context attributes).
Retry Policy#
When the call to the target fails, Scheduler retries with exponential backoff until either limit in the schedule's RetryPolicy runs out (RetryPolicy): MaximumRetryAttempts, 0 to 185, or MaximumEventAgeInSeconds, 60 to 86,400. Set both. A daily job retried for 24 hours can collide with the next day's run.
Dead-Letter Queues#
When an invocation fails for good, Scheduler can send the event to the standard SQS queue (not FIFO) in DeadLetterConfig, if the execution role has sqs:SendMessage (DLQ). Each message carries the target API's ERROR_CODE and ERROR_MESSAGE, SCHEDULED_TIME, and RETRY_ATTEMPTS. Without a DLQ, an event that exhausts its retries shows up only in the InvocationDroppedCount metric.
Scheduler Failures vs. Job Failures#
Scheduler's part ends when the target's API accepts the call:
- Lambda. Scheduler invokes functions asynchronously (Lambda guide), so the call succeeds once Lambda queues the event. If the function throws or times out, Lambda runs it "two more times, with a one-minute wait between the first two attempts, and two minutes between the second and third attempts" (async error handling). The function's own
MaximumRetryAttempts(0-2) andMaximumEventAgeInSeconds(60-21,600) tune that (PutFunctionEventInvokeConfig), and an on-failure destination records events that fail every attempt. - ECS. Scheduler calls
RunTask. A task that exits non-zero or fails to pull its image isn't a Scheduler failure; ECS reports why it stopped inECS Task State Changeevents (task events). - Step Functions. Scheduler calls
StartExecution; an execution that fails later is the state machine's failure.
So the schedule's DLQ holds failed invocations, such as a permissions error, while errors the function returns go to the function's own DLQ or destination (SAM ScheduleV2). Retrying failed cron jobs covers choosing which layer retries.
CloudWatch Metrics for EventBridge Scheduler#
Scheduler publishes these in the AWS/Scheduler namespace (metrics):
| Metric | Emitted when |
|---|---|
InvocationAttemptCount |
Every invocation attempt |
TargetErrorCount |
The target API returns an exception |
TargetErrorThrottledCount |
The target API throttles the call |
InvocationThrottleCount |
Scheduler throttles invocations over your quota |
InvocationDroppedCount |
Scheduler stops trying after the retry policy runs out |
InvocationsSentToDeadLetterCount |
An event reaches the DLQ |
InvocationsFailedToBeSentToDeadLetterCount |
An event can't be written to the DLQ |
Two limits matter for alarms. The only dimension is ScheduleGroup, so an alarm watches a group, not a schedule; give an important schedule its own group (500 groups per Region by default) to alarm on it alone. And the metrics are best-effort: "A data point for a given minute might be delayed before being available through CloudWatch, or it might not be delivered at all."
The Execution Role#
Every schedule's target names an IAM role, RoleArn, that Scheduler assumes to make the call. Its trust policy must allow scheduler.amazonaws.com, and AWS recommends conditions that tie it to your account and a schedule group (confused deputy prevention):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "scheduler.amazonaws.com" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"aws:SourceAccount": "123456789012",
"aws:SourceArn": "arn:aws:scheduler:us-east-1:123456789012:schedule-group/default"
}
}
}
]
}The permissions policy grants the target's API: lambda:InvokeFunction, states:StartExecution, or ecs:RunTask plus iam:PassRole for the task's execution and task roles (ECS), and sqs:SendMessage for the DLQ. KMS-encrypted targets also need kms:Decrypt and kms:GenerateDataKey (troubleshooting).
These mistakes fail every invocation while the schedule looks fine; TargetErrorCount climbs and the target never logs anything:
- The trust policy names
events.amazonaws.com, the principal for rule roles such as ECS'secsEventsRole. Scheduler can't assume it. aws:SourceArnnames a schedule. AWS: "The ARN you specify must be a schedule group."- The permission is on the function, not the role. A Lambda resource-based policy letting
events.amazonaws.cominvoke the function, the usual setup for a rule, grants nothing to Scheduler's role. - The role can't pass the ECS task's roles, so
RunTaskis denied.
If TargetErrorCount climbs while the DLQ stays empty, AWS says that "likely indicates a permissions issue preventing EventBridge Scheduler from writing to the DLQ itself."
Why an EventBridge Schedule Didn't Run#
| Cause | What to check |
|---|---|
| Disabled, expired, or reset by an update | aws scheduler get-schedule --name order-export: State, StartDate, EndDate, ScheduleExpressionTimezone. UpdateSchedule sets every optional field it omits "to its system-default value" (UpdateSchedule), so an update without the zone moves the schedule to UTC |
| Crontab day numbers | 1 is Sunday and 2 is Monday |
| Every invocation fails | TargetErrorCount and the DLQ's ERROR_CODE: the execution role, or a universal target whose Input the target API rejects, which Scheduler "does not validate" at creation (universal targets) |
| Lambda accepted the event | The function's Errors and AsyncEventsDropped metrics and its logs. At reserved concurrency 0, Lambda sends events to its DLQ or on-failure destination "without any retries" |
Monitoring an EventBridge Scheduled Job#
Alarms on TargetErrorCount, InvocationDroppedCount, or InvocationsSentToDeadLetterCount, plus the function's Errors, catch a failed invocation and a job that throws. Two failures move none of them:
- A schedule that stopped invoking. A disabled, deleted, or expired schedule makes no attempts and emits no errors. An alarm on
InvocationAttemptCountthat treats missing data as breaching can notice silence, but only per schedule group and only as reliably as best-effort metrics. - A run that succeeded at nothing. A function that returns normally after exporting zero rows is a success to Lambda.
Both are the job of a dead man's switch: the job reports each run to a monitor outside AWS that knows the schedule, and a missing report is the alert.
Report Each Run to CronRadar Over HTTP#
There is no CronRadar SDK for EventBridge Scheduler and no auto-discovery, and CronRadar's Python SDK isn't available right now, so each target reports with its own HTTP check-ins. This handler for the python3.14 runtime uses only the standard library:
# handler.py: the Lambda target of an EventBridge Scheduler schedule (python3.14).
import base64
import json
import os
import urllib.parse
import urllib.request
from orders_export import export_orders # the job itself, deployed with this file
PING_URL = "https://cron.life/api/v1/ping/order-export"
SCHEDULE = "15 10 * * *" # 06:15 New York is 10:15 UTC in summer, 11:15 in winter
GRACE_SECONDS = 4500 # the winter hour (3,600 s) plus 900 s of run time
def report(kind, run_id, message=None, outcomes=None):
"""Send one CronRadar check-in. Gives up after 5 seconds and never raises."""
try:
params = {"schedule": SCHEDULE, "gracePeriod": GRACE_SECONDS, "run_id": run_id}
if message:
params["message"] = message[:1000]
query = urllib.parse.urlencode(params, quote_via=urllib.parse.quote)
token = base64.b64encode(f"{os.environ['CRONRADAR_API_KEY']}:".encode()).decode()
headers = {"Authorization": f"Basic {token}"}
body = None
if outcomes is not None:
body = json.dumps({"outcomes": outcomes}).encode()
headers["Content-Type"] = "application/json"
request = urllib.request.Request(
f"{PING_URL}/{kind}?{query}", data=body, headers=headers, method="POST"
)
with urllib.request.urlopen(request, timeout=5):
pass
except Exception as error: # a monitoring problem must never fail the job
print(f"CronRadar {kind} check-in failed: {error!r}")
def handler(event, context):
run_id = context.aws_request_id # Lambda keeps it when it retries this event
report("start", run_id)
try:
rows = export_orders(event.get("scheduled_time"), context)
except Exception as error:
message = f"{type(error).__name__}; see CloudWatch Logs for request {run_id}"
report("fail", run_id, message)
raise
report("complete", run_id, outcomes={"rows_exported": rows})
return {"rows_exported": rows}- Monitoring can't break the job. Each check-in gives up after 5 seconds (urllib applies the timeout to each blocking step, not the whole request), and its errors are logged and dropped. The job's exception is re-raised after
fail, so Lambda still records and retries it.failnames only the exception type, since exception text can carry data that shouldn't leave your account. - One
run_idper event. Duplicate invocations "that have the same request ID indicate that the Lambda service retried the function" (re:Post). The handler can't tell which attempt is last, so a failed first attempt alerts at once and a successful retry records the recovery. - A hang has to stop itself. At the function's timeout, at most 900 seconds (timeout), Lambda ends the run before
failis sent. CronRadar flags a run as stuck only when itsstartis still unanswered at the next expected run plus the grace period, and every check-in moves that deadline forward, so a job that times out nightly may never be flagged. Haveexport_orderscheckcontext.get_remaining_time_in_millis()between batches and raise when under a minute remains. - Outcomes catch the empty run. Add the expectation
rows_exported >= 1in the monitor's settings, and a run that exports nothing opens an incident. Outcome values must be finite numbers or booleans, soexport_ordersreturns an integer. - The first check-in creates the monitor, and the next recreates it if deleted.
scheduleandgracePeriodapply only at creation; change an existing monitor in the dashboard.
In AWS SAM, a ScheduleV2 event creates the schedule, and an execution role when you omit RoleArn:
Transform: AWS::Serverless-2016-10-31
Resources:
OrderExportFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: src/
Handler: handler.handler
Runtime: python3.14
Timeout: 900
Environment:
Variables:
CRONRADAR_API_KEY: '{{resolve:secretsmanager:cronradar/production:SecretString:api_key}}'
Events:
NightlyExport:
Type: ScheduleV2
Properties:
Name: order-export
ScheduleExpression: 'cron(15 6 * * ? *)'
ScheduleExpressionTimezone: America/New_York
FlexibleTimeWindow:
Mode: 'OFF'
Input: '{"scheduled_time": "<aws.scheduler.scheduled-time>"}'
RetryPolicy:
MaximumRetryAttempts: 10
MaximumEventAgeInSeconds: 3600
DeadLetterConfig:
Type: SQSThe {{resolve:secretsmanager:...}} reference copies the key from Secrets Manager into the environment at deploy time. CloudFormation reads it only when it creates or updates the function, so a rotated key needs a redeploy, and the value "may show up in the service whose resource it's being used in": anyone who can view the function's environment variables can read it (dynamic references). To avoid that, or to use a Parameter Store SecureString (ssm-secure references don't work on Lambda functions), fetch it at run time with the Parameters and Secrets Lambda Extension and set CRONRADAR_API_KEY before the first check-in. An ECS task gets the variable from its task definition's secrets (ECS secrets). Use one CronRadar application and key per environment.
Convert the Schedule for CronRadar#
CronRadar reads five-field cron in UTC, with Sunday as 0 and no year, ?, L, W, or #; when both day fields are restricted, a date must match both. Drop the year, replace ? with *, subtract one from numeric days of week, and convert local times to UTC. For a zone with daylight saving, use the earlier UTC time and a grace period of at least an hour plus the run time.
| EventBridge Scheduler | CronRadar schedule |
Why |
|---|---|---|
cron(30 2 * * ? *) |
30 2 * * * |
Year dropped, ? becomes * |
cron(0 9 ? * 2-6 *) |
0 9 * * 1-5 |
AWS 2-6 is Monday-Friday; names carry over unchanged |
cron(0 3 ? * 1 *) |
0 3 * * 0 |
Sunday is 1 on AWS, 0 on CronRadar |
cron(15 6 * * ? *) in America/New_York |
15 10 * * *, grace of 3,600 + run time |
The winter run arrives an hour later |
cron(0 9 ? * 2#1 *) |
0 9 1-7 * 1 |
Both fields must match: the first Monday |
cron(0 12 * * ? 2027) |
0 12 * * * |
No year field; pause the monitor when the year ends |
rate(5 minutes) |
*/5 * * * * |
Only after changing AWS to cron(0/5 * * * ? *) |
A rate() schedule's clock times depend on when it started, so replace it with the cron() it matches. L, W, 6L, and rates such as rate(10 days) have no five-field form: schedule the AWS side on every candidate date, let the job decide, and check in on every run, including early exits, which mustn't fail an outcome expectation. For the last day of the month, that's cron(0 23 28-31 * ? *) on AWS, 0 23 28-31 * * on CronRadar, and this check on the scheduled_time from the Input:
from datetime import datetime, timedelta
def is_last_day_of_month(scheduled_time):
"""scheduled_time is <aws.scheduler.scheduled-time>, such as 2026-10-31T23:00:00Z."""
day = datetime.fromisoformat(scheduled_time).date()
return (day + timedelta(days=1)).day == 1For the last Friday, schedule every Friday and run when seven days later is in the next month; for 15W, schedule the 14th through 16th. Then invoke the function once, confirm order-export shows a success in the right CronRadar application, and let a scheduled run arrive.
Frequently Asked Questions#
What is the cron format for EventBridge Scheduler?#
Six fields inside cron(): minutes, hours, day-of-month, month, day-of-week, and year. One day field must be ?, day-of-week runs 1-7 from Sunday, and L, W, and # are supported. Daily at 02:30 is cron(30 2 * * ? *), in UTC unless the schedule sets ScheduleExpressionTimezone.
What is the difference between EventBridge Scheduler and EventBridge rules?#
Scheduled rules are the legacy option: UTC only, recurring only, and 300 per event bus in most Regions. EventBridge Scheduler adds time zones, one-time schedules, flexible time windows, universal targets, and a default quota of 10 million schedules per Region. AWS recommends Scheduler.
Does EventBridge Scheduler retry failed invocations?#
Yes, when the call to the target fails: up to 185 retries within at most 24 hours, with exponential backoff, then an optional SQS dead-letter queue. It doesn't retry the job's own failure: Lambda retries a function that throws, two more times by default, and an ECS task's non-zero exit is ECS's to report, not Scheduler's to retry.
Why didn't my EventBridge schedule run?#
Check its state, dates, and time zone with aws scheduler get-schedule; an UpdateSchedule call resets every optional field it omits. Then check day-of-week numbering, where 2 is Monday, the daylight saving gap, and TargetErrorCount, which climbs when the execution role or target input is wrong.
How do I monitor a scheduled Lambda function?#
Alarm on the function's Errors and Scheduler's TargetErrorCount, and have the function check in with a monitor outside AWS. With CronRadar, the handler reports start, complete, and fail over HTTP with a five-field UTC schedule, so a run that never happens alerts once its grace period passes.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.