To run an AWS Lambda function on a schedule, you create the schedule outside the function and point it at the function ARN. In 2026 that means an EventBridge Scheduler schedule: a cron(), rate() or at() expression, a timezone, a target and its own retry policy. Legacy EventBridge cron rules still work, still fire in UTC only, and are worth keeping only for jobs already running on them. The wiring takes ten lines of YAML; what breaks later is timezones, double fires and silence.
How do you run a Lambda function on a schedule?
Lambda has no internal timer. A function never wakes itself up: something outside invokes it on a clock, and the only real decision is which something. Three options exist in practice, and only the first two are actual schedulers.
EventBridge Scheduler is a service built for this: timezone-aware cron, one-time runs, a retry policy and a dead-letter queue per schedule, quotas counted in millions of schedules. Legacy EventBridge scheduled rules are the older mechanism, attached to an event bus, UTC only. Step Functions Wait states are the third, and they schedule nothing: they pause a workflow that is already running. We compare the three in our EventBridge Scheduler versus cron rules article; this one is about building the job.
Whichever you pick, the parts are identical: a schedule expression, a target (the function ARN), an IAM role that allows the scheduler to invoke it, and an optional JSON payload the handler receives as its event.
How do you create an EventBridge Scheduler schedule?
When the job ships with the function, declare it in SAM or CloudFormation. The SAM event type is ScheduleV2, which creates an EventBridge Scheduler schedule plus the invoke role. Watch the name: the older Schedule type creates a legacy rule instead, and the two differ by two characters.
# template.yaml: the schedule ships with the function
Resources:
NightlyReport:
Type: AWS::Serverless::Function
Properties:
Handler: report.handler
Runtime: nodejs22.x
Events:
Weekdays:
Type: ScheduleV2
Properties:
ScheduleExpression: cron(30 6 ? * MON-FRI *)
ScheduleExpressionTimezone: Europe/Paris
FlexibleTimeWindow:
Mode: "OFF"
RetryPolicy:
MaximumRetryAttempts: 3
DeadLetterConfig:
Arn: !GetAtt ScheduleDlq.Arn
Quote that OFF, by the way: unquoted, YAML reads it as a boolean and the deployment fails on a type error. For schedules created at runtime, one per user or per order, the same fields exist on the API. Note where they sit: the retry policy and the dead-letter queue belong to the target, not to the schedule itself.
// Created at runtime with AWS SDK v3
import { SchedulerClient, CreateScheduleCommand } from "@aws-sdk/client-scheduler";
const scheduler = new SchedulerClient({});
await scheduler.send(new CreateScheduleCommand({
Name: "nightly-report",
ScheduleExpression: "cron(30 6 ? * MON-FRI *)",
ScheduleExpressionTimezone: "Europe/Paris",
FlexibleTimeWindow: { Mode: "OFF" },
Target: {
Arn: "arn:aws:lambda:eu-west-1:123456789012:function:nightly-report",
RoleArn: "arn:aws:iam::123456789012:role/scheduler-invoke-lambda",
Input: JSON.stringify({ job: "nightly-report" }),
RetryPolicy: { MaximumRetryAttempts: 3, MaximumEventAgeInSeconds: 3600 },
DeadLetterConfig: { Arn: "arn:aws:sqs:eu-west-1:123456789012:schedule-dlq" },
},
}));
Two fields earn their keep. FlexibleTimeWindow set to OFF means "fire on that minute"; set to FLEXIBLE with a window, Scheduler spreads invocations across it, which is what you want when hundreds of schedules land on the same minute. And an at() expression gives you a one-off run, with ActionAfterCompletion set to DELETE so the schedule removes itself after firing.
How do EventBridge cron rules work, and when are they still fine?
A scheduled rule accepts one of two expressions. rate(value unit) is the easy one: rate(5 minutes), rate(1 hour), rate(7 days). The unit is singular for a value of 1 and plural above it, and one minute is the floor.
cron() is where the surprises live. AWS uses six fields, not the five of Unix crontab: minute, hour, day-of-month, month, day-of-week and year. Day-of-week starts at Sunday as day 1, so Monday to Friday is 2-6, or MON-FRI. And you cannot give a value to both day-of-month and day-of-week: one of the two must be a question mark. A daily 08:00 job is cron(0 8 * * ? *). The five-field version copied from a Linux crontab is rejected, and the same six-field syntax applies to Scheduler.
# Legacy rule: same function, UTC, no timezone field exists
Events:
Nightly:
Type: Schedule
Properties:
Schedule: cron(0 5 ? * MON-FRI *)
Everything a rule fires happens in UTC, and a rule cannot express a one-off run. That leaves one reasonable case for keeping rules: a small, stable set of genuinely UTC jobs that already run, already have alarms, and would gain nothing from a migration.
Scheduler or cron rule: which one for your job?
For a new job the answer is Scheduler, and the table says why. The first three rows are capabilities a rule simply does not have; the last row is the only scenario where rules still win.
| Criterion | EventBridge Scheduler | EventBridge cron rule |
|---|---|---|
| Expressions | cron(), rate(), and at() for one-offs | cron() and rate() only |
| Timezone | Any IANA timezone, daylight saving handled | UTC only |
| Retry and DLQ | Per schedule: max attempts, max event age, queue | Per target on the rule |
| Targets | Lambda, plus hundreds of AWS APIs called directly | Bus targets (Lambda, SQS, SNS, and more) |
| Scale | Quotas in the millions of schedules | A few hundred rules per bus (default quota) |
| How you declare it | SAM ScheduleV2, AWS::Scheduler::Schedule | SAM Schedule, AWS::Events::Rule |
| When it wins | New jobs, local time, one-offs, per-user schedules | UTC jobs already deployed and already monitored |
Scheduled jobs firing at the wrong hour, twice, or not at all? Describe your system: a one-page diagnosis within 48 hours.
Get my diagnosis →How do you handle timezones and daylight saving time?
If a human reads the output, the schedule belongs in local time. Set ScheduleExpressionTimezone to an IANA name such as Europe/Paris and the schedule follows the local clock: 06:30 stays 06:30 in January and in July. Put the same job on a UTC rule and it lands one hour off for half the year, which is the classic twice-a-year incident nobody logs because nothing failed.
Two edge cases are worth knowing before you trust a local time blindly. When clocks jump forward, a local time inside the skipped hour does not exist that day; when they fall back, a local time inside the repeated hour occurs twice on the wall clock. AWS documents the behaviour for both cases in the schedule types reference. The simple defence is to keep schedules out of the small hours where the switch happens, and to run machine-facing jobs in UTC on purpose.
Inside the handler, stay in UTC. Compute the business day from the scheduled time the scheduler passes in, never from a local clock, and log which timezone the job assumed so the next reader does not have to guess.
What breaks once a scheduled Lambda is in production?
A schedule can fire twice. Delivery is at least once, a retry can follow a timeout while the first invocation is still running, and a redeployed stack can leave two schedules pointing at the same function. So make the job idempotent. Scheduler fills in context attributes in the input payload, which gives you a deduplication key for free:
// Scheduler substitutes these at invocation time
Input: JSON.stringify({
job: "nightly-report",
scheduledTime: "<aws.scheduler.scheduled-time>",
executionId: "<aws.scheduler.execution-id>",
attempt: "<aws.scheduler.attempt-number>",
}),
Write that key to DynamoDB with a conditional put before the work starts, and exit early when the write fails. Jobs that run longer than their own interval need the same guard for a different reason: the next invocation arrives before the previous one finishes.
Then there is silence. Scheduled invocations are asynchronous, so nobody is waiting on the response: Lambda retries a failed async invocation and then drops the event unless a destination or a dead-letter queue catches it. Attach a dead-letter queue to the schedule, alarm on its depth, and alarm on the function Errors metric. Add one more alarm that most teams miss: invocations below one over the period, with missing data treated as breaching. A job that silently stopped firing produces no errors at all, so the error alarm stays green while the work stops happening.
Why is a polling cron the wrong default?
The anti-pattern is a function on rate(1 minute) that scans a table for due work. It fires more than a thousand times a day to do nothing most of the time, adds up to a minute of lag to every item, and degrades quietly as the table grows. The replacement is one one-off schedule per item, created when the item is created, with ActionAfterCompletion set to DELETE. With quotas in the millions, one schedule per reminder is the intended usage, not an abuse.
Keep polling only where the work genuinely is "look at everything on a rhythm": a nightly reconciliation, a daily export, a cleanup sweep. The other polling cron worth questioning is the warming ping, which keeps exactly one execution environment warm and nothing more, as we cover in our article on Lambda warm starts.
Cost follows the same shape. Scheduler bills per invocation past a monthly free allowance, scheduled rules ride on EventBridge pricing, and the function itself bills on requests and GB-seconds under Lambda pricing. The schedule is almost never the expensive part: frequency multiplied by function duration is. A minute-by-minute poll pays that product all day, every day, while an event-driven schedule pays it only when something actually happens. Check the current numbers and free allowances on the official pages before modelling anything.
If you are weighing this against the other AWS building blocks, the short version of each call lives in our AWS decision guides.
Scheduled Lambda checklist
- New job: EventBridge Scheduler, declared as ScheduleV2 in SAM, never a new legacy rule.
- Anything a human reads on a clock: set ScheduleExpressionTimezone, do not hand-roll an offset.
- Six cron fields, a question mark in day-of-month or day-of-week, Sunday as day 1.
- One idempotency key per run, written conditionally before the work starts.
- A dead-letter queue on every schedule, with an alarm on its depth.
- An alarm on missing invocations, treating missing data as breaching.
- Per-item work: one self-deleting at() schedule each, not a one-minute poll.
- Target is a queue, a workflow or another AWS API: call it from the schedule, drop the glue function.