Skip to content
AWS Cron Expressions: EventBridge and Scheduler Syntax

Click to use (opens in a new tab)

AWS Cron Expressions: EventBridge and Scheduler Syntax

August 14, 2026 by Chat2DBChat2DB Team

If you have ever pasted a working crontab line into an EventBridge rule and gotten Parameter ScheduleExpression is not valid, you have met the AWS cron dialect. An AWS cron expression is close enough to Unix cron to look familiar and different enough to reject most expressions copied from a server. This article covers the exact syntax used by EventBridge Rules, EventBridge Scheduler, and other AWS services, the ? wildcard rule that causes most validation errors, the extra L/W/# operators, rate() as an alternative, and how to apply all of it to scheduling database work on RDS and Aurora.

How AWS Cron Differs from Standard Cron

Unix cron uses five fields. AWS cron uses six, adding a year field at the end:

cron(minutes hours day-of-month month day-of-week year)

┌───────────── minutes       (0–59)
│ ┌───────────── hours       (0–23)
│ │ ┌───────────── day-of-month (1–31, plus L W ?)
│ │ │ ┌───────────── month   (1–12 or JAN–DEC)
│ │ │ │ ┌───────────── day-of-week (1–7 or SUN–SAT, plus L # ?)
│ │ │ │ │ ┌───────────── year (1970–2199)
│ │ │ │ │ │
cron(0 12 * * ? *)

The differences that matter in practice:

  1. Six fields, not five. The trailing year field is mandatory. * is almost always what you want there, but you can pin a schedule to 2026 or a range like 2026-2028.
  2. The expression is wrapped in cron(...). The schedule expression string is literally cron(0 12 * * ? *), not the bare fields.
  3. Day-of-week is 1–7, not 0–6. In AWS, 1 = Sunday and 7 = Saturday (matching Quartz), whereas Unix cron uses 0 (or 7) = Sunday and 1 = Monday. This off-by-one silently shifts schedules copied from crontab — MON-FRI by name is safer than numbers.
  4. No seconds field. Unlike Quartz, AWS cron has no leading seconds field; the finest granularity is one minute.
  5. ? is required in one of the two day fields — the next section.

The ? Wildcard Requirement

In AWS cron you cannot use * in both day-of-month and day-of-week. Exactly one of the two must be ?, which means "no specific value — let the other day field decide". This is inherited from Quartz and resolves the classic Unix ambiguity where two restricted day fields combine with OR.

cron(0 0 * * * *)      # INVALID — both day fields specified
cron(0 0 * * ? *)      # VALID — every day (day-of-week unspecified)
cron(0 0 ? * MON *)    # VALID — every Monday (day-of-month unspecified)

Rule of thumb: if you constrain day-of-week, put ? in day-of-month; otherwise put ? in day-of-week. If you see Parameter ScheduleExpression is not valid, check this first — it accounts for the majority of failures.

Extra Syntax: L, W, and

AWS cron supports three operators that standard cron lacks:

  • L (last). In day-of-month, L is the last day of the month, and L-3 is the third-to-last day. In day-of-week, 6L means "the last Friday of the month" (6 = Friday in AWS numbering).
  • W (weekday). Day-of-month only. 15W means "the weekday nearest the 15th" — if the 15th is a Saturday it fires Friday the 14th; if a Sunday, Monday the 16th. LW means the last weekday of the month, a common payroll-style schedule.
  • # (nth occurrence). Day-of-week only. MON#1 (or 2#1) means "the first Monday of the month"; FRI#3 the third Friday. Only one # value per expression.
cron(0 8 L * ? *)        # 08:00 UTC on the last day of every month
cron(0 8 LW * ? *)       # 08:00 UTC on the last weekday of every month
cron(0 9 ? * MON#1 *)    # 09:00 UTC on the first Monday of every month
cron(30 17 ? * 6L *)     # 17:30 UTC on the last Friday of every month

rate() Expressions: The Simple Alternative

For plain fixed intervals, AWS offers rate(value unit) with units minute, minutes, hour, hours, day, days (singular when the value is 1):

rate(5 minutes)    # every 5 minutes
rate(1 hour)       # every hour
rate(7 days)       # weekly

rate() starts counting from when the rule or schedule is created or enabled, so the phase is arbitrary — rate(1 day) runs at whatever time of day you created it. Use cron() whenever the wall-clock time matters; use rate() when only the interval matters. EventBridge Scheduler additionally supports one-off schedules with at(2026-08-20T02:30:00).

Copy-Paste Examples for Common Schedules

ScheduleExpression
Every 5 minutescron(0/5 * * * ? *) or rate(5 minutes)
Every 15 minutescron(0/15 * * * ? *)
Every hour, on the hourcron(0 * * * ? *)
Daily at midnight UTCcron(0 0 * * ? *)
Daily at 02:30 UTCcron(30 2 * * ? *)
Weekdays at 09:00cron(0 9 ? * MON-FRI *)
Weekdays, every 30 min, 09:00–17:59cron(0/30 9-17 ? * MON-FRI *)
First Monday of the month, 09:00cron(0 9 ? * MON#1 *)
First day of month, midnightcron(0 0 1 * ? *)
Last weekday of month, 18:00cron(0 18 LW * ? *)
Quarterly (Jan/Apr/Jul/Oct 1st)cron(0 0 1 1,4,7,10 ? *)
Once a year, Jan 1cron(0 0 1 1 ? *)

Note the AWS step idiom 0/5 (start at 0, step 5). */5 is also accepted and equivalent; 0/5 makes the starting phase explicit.

EventBridge Scheduler vs EventBridge Rules

AWS now has two scheduling services, and the differences are significant:

CapabilityEventBridge Rules (legacy)EventBridge Scheduler
TimezoneUTC onlyAny IANA timezone (--schedule-expression-timezone)
DST handlingN/A (UTC)Follows the named timezone through DST
Flexible time windowsNoYes — jitter over 1 min to 4 hours
One-off schedulesNoYes, at(...)
TargetsBus targets (~20 types)270+ services via universal targets
Quota300 rules/bus (soft)1M schedules (soft)

For new work, use EventBridge Scheduler. A schedule that fires at 09:00 New York time year-round, DST included:

aws scheduler create-schedule \
  --name daily-report-9am-et \
  --schedule-expression "cron(0 9 ? * MON-FRI *)" \
  --schedule-expression-timezone "America/New_York" \
  --flexible-time-window Mode=OFF \
  --target '{
    "Arn": "arn:aws:lambda:us-east-1:123456789012:function:daily-report",
    "RoleArn": "arn:aws:iam::123456789012:role/scheduler-invoke-lambda"
  }'

Flexible time windows (Mode=FLEXIBLE,MaximumWindowInMinutes=15) let Scheduler invoke the target at a random point within the window after the scheduled time — useful to avoid thundering-herd load when hundreds of schedules share the same cron minute.

The legacy Rules equivalent is UTC-only:

aws events put-rule \
  --name nightly-batch \
  --schedule-expression "cron(0 2 * * ? *)"   # 02:00 UTC, always

Scheduling RDS and Aurora Database Tasks

Manual snapshots on a schedule

Automated RDS backups run in a backup window, but retained manual snapshots need orchestration. EventBridge Scheduler can call the RDS API directly with a universal target — no Lambda required:

aws scheduler create-schedule \
  --name weekly-manual-snapshot \
  --schedule-expression "cron(0 3 ? * SUN *)" \
  --schedule-expression-timezone "UTC" \
  --flexible-time-window Mode=OFF \
  --target '{
    "Arn": "arn:aws:scheduler:::aws-sdk:rds:createDBSnapshot",
    "RoleArn": "arn:aws:iam::123456789012:role/scheduler-rds-snapshot",
    "Input": "{\"DbInstanceIdentifier\": \"prod-db\", \"DbSnapshotIdentifier\": \"prod-db-weekly-<aws.scheduler.scheduled-time>\"}"
  }'

The same pattern works for stopDBInstance/startDBInstance to shut down dev databases outside business hours — cron(0 20 ? * MON-FRI *) to stop, cron(0 7 ? * MON-FRI *) to start.

A Lambda that runs SQL on schedule

For maintenance SQL — purging expired rows, refreshing aggregates — schedule a Lambda that connects to the database:

# lambda_function.py — triggered by cron(30 3 * * ? *)
import os
import pg8000.native
 
def handler(event, context):
    conn = pg8000.native.Connection(
        user=os.environ["DB_USER"],
        password=os.environ["DB_PASSWORD"],   # prefer Secrets Manager in production
        host=os.environ["DB_HOST"],
        database=os.environ["DB_NAME"],
    )
    deleted = conn.run(
        "DELETE FROM events WHERE created_at < now() - interval '90 days'"
    )
    conn.close()
    return {"status": "ok"}

Two operational notes. First, the Lambda needs VPC access to reach RDS, and an RDS Proxy in front of Aurora avoids connection storms when schedules overlap. Second, test the SQL interactively before wiring it into a schedule — a mis-scoped DELETE that runs unattended every night is an expensive way to find a missing WHERE clause. Chat2DB (opens in a new tab) is convenient here: connect to the RDS instance, run the exact statement with EXPLAIN, verify affected row counts, then paste the vetted SQL into the Lambda. For Aurora PostgreSQL you can also skip Lambda entirely and use the pg_cron extension, which accepts standard 5-field expressions — one more reason to keep the two dialects straight, and the cron expression generator (opens in a new tab) can translate a schedule into either format and preview its next fire times.

Common Errors and Gotchas

  • Parameter ScheduleExpression is not valid. Usually one of: * in both day fields (use ? in one), only five fields (add the year), or a bare expression without the cron(...) wrapper.
  • EventBridge Rules are UTC-only. cron(0 9 ? * MON-FRI *) in a Rule means 09:00 UTC forever; your "9 AM standup trigger" drifts an hour when DST changes local time. Migrate to Scheduler and set --schedule-expression-timezone if wall-clock local time matters.
  • No seconds field, one-minute floor. AWS cron cannot express sub-minute schedules, and rate(30 seconds) is invalid. For sub-minute work, have a per-minute schedule invoke a Lambda that loops with sleeps, or use SQS delay queues / Step Functions waits.
  • Day-of-week numbering. AWS 1 = Sunday. A Unix 1-5 (Mon–Fri) becomes 2-6 in AWS numeric form. Use names (MON-FRI) and the problem disappears.
  • Delivery is at-least-once and not to-the-second. EventBridge invokes targets within the minute of the scheduled time, and rare duplicate invocations happen. Make scheduled handlers idempotent — e.g., key snapshot names by scheduled time, or use DELETE ... WHERE predicates that are safe to re-run.
  • Disabled schedules still exist. A Scheduler schedule with --state DISABLED doesn't fire but still counts against quotas and still shows in aws scheduler list-schedules — clean up experiments.

Conclusion

AWS cron is Quartz-flavored: six fields ending in year, a mandatory ? in one day field, and the powerful L/W/# operators that standard cron lacks. Reach for rate() when only the interval matters, cron() when the wall clock matters, and EventBridge Scheduler rather than legacy Rules whenever you need timezones, flexible windows, or direct SDK targets like RDS snapshots. Keep the two dialects mentally separate from crontab syntax — most "invalid expression" errors are just a five-field habit colliding with a six-field parser.