rrule→dates

RRULE in plain English

An RRULE is the one-line recurrence rule inside a calendar event, defined by RFC 5545: FREQ says how often the event repeats, INTERVAL how many of those periods to skip between repeats, the BY… parts narrow down which days count, and COUNT or UNTIL says when to stop. FREQ=WEEKLY;BYDAY=MO means every Monday.

Every calendar application that imports or exports .ics files speaks the same recurrence language, and it is compact enough to read once you know the parts. This page covers each part, then a set of worked rules you can copy, then the behaviour that surprises people most: the dates a rule quietly does not produce.

The shape of a rule

A rule is a list of NAME=value pairs separated by semicolons, on a line that starts RRULE:. Order does not matter, names are case-insensitive, and only FREQ is required. It always sits beside a DTSTART, which is both the first occurrence and the source of anything the rule leaves unsaid — the time of day, and for a plain monthly rule, the day of the month.

DTSTART;VALUE=DATE:20270104
RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=10

That pair means: ten Mondays, starting Monday 4 January 2027.

Every part, in plain English

PartMeansExample
FREQThe base period: DAILY, WEEKLY, MONTHLY, YEARLY (also HOURLY, MINUTELY, SECONDLY, which calendar apps rarely offer).FREQ=MONTHLY
INTERVALRepeat every n periods. Defaults to 1.INTERVAL=2 — every other one
BYDAYWeekdays, as MO TU WE TH FR SA SU. In a monthly or yearly rule a number in front picks one occurrence: 2TU is the second Tuesday, -1FR the last Friday.BYDAY=MO,WE,FR
BYMONTHDAYDays of the month, 1 to 31, or counted from the end with negatives: -1 is the last day.BYMONTHDAY=15
BYMONTHMonths, 1 to 12.BYMONTH=3,9
BYSETPOSAfter the other BY… parts have produced a set of days for each period, keep only the nth of them. Negative counts from the end.BYSETPOS=-1 — the last one
COUNTStop after this many occurrences. The DTSTART counts as the first.COUNT=12
UNTILStop after this date. Inclusive — an occurrence on the UNTIL date still happens. Never use it together with COUNT.UNTIL=20271231
WKSTWhich day a week starts on. Defaults to Monday. Only changes the result for weekly rules with an INTERVAL above 1 — see below.WKST=SU

With neither COUNT nor UNTIL the rule repeats forever, which is legal and is what an ordinary “repeats every week” event in your calendar is.

Rules you can copy

You wantRRULE
Every MondayFREQ=WEEKLY;BYDAY=MO
Every weekdayFREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR
Every other Tuesday and ThursdayFREQ=WEEKLY;INTERVAL=2;BYDAY=TU,TH
The 15th of every monthFREQ=MONTHLY;BYMONTHDAY=15
The third Tuesday of every monthFREQ=MONTHLY;BYDAY=3TU
The last Friday of every monthFREQ=MONTHLY;BYDAY=-1FR
The last day of every monthFREQ=MONTHLY;BYMONTHDAY=-1
The last working day of every monthFREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1
The first Monday of each quarterFREQ=YEARLY;BYMONTH=1,4,7,10;BYDAY=1MO
US ThanksgivingFREQ=YEARLY;BYMONTH=11;BYDAY=4TH
Daily for ten daysFREQ=DAILY;COUNT=10

Two readings of “monthly” hide in that table. BYMONTHDAY=15 keeps the date fixed and lets the weekday wander; BYDAY=3TU keeps the weekday fixed and lets the date wander between the 15th and the 21st. Most real meetings are the second kind, and most “repeat monthly” buttons produce the first — worth checking before a series goes out.

BYSETPOS is the part that makes the last-working-day rule possible. The BYDAY list produces every weekday of the month; BYSETPOS=-1 keeps the last of them. There is no other way to say “last weekday” in an RRULE, because the last day of a month is not always one.

The dates a rule skips

This is the behaviour almost no reference explains, and the one that produces the “my reminder didn’t fire” complaints. RFC 5545 says that when a rule would generate a date that does not exist, that instance is ignored and not counted. It is not moved to the nearest real date.

DTSTART;VALUE=DATE:20270131
RRULE:FREQ=MONTHLY;BYMONTHDAY=31;COUNT=6

That is not “the 31st, or the last day of shorter months”. It is: 31 January, 31 March, 31 May, 31 July, 31 August, 31 October 2027. February, April, June and September simply have no occurrence, and because skipped dates do not count towards COUNT, the sixth occurrence lands in October rather than June. The same happens to a yearly rule on 29 February, which occurs only in leap years. A plain FREQ=MONTHLY starting on the 31st behaves identically, since the day number comes from DTSTART.

If you meant “the last day of every month”, the rule is BYMONTHDAY=-1. If you meant “the 30th, or the last day when the month is shorter”, that is BYMONTHDAY=28,29,30;BYSETPOS=-1 — the latest of those three that exists in each month.

The recurring dates generator lists these skips alongside the dates it did produce — how many periods were skipped and which ones — rather than leaving you to spot the gaps in a list of forty dates.

WKST, and why “every other week” can mean two things

With INTERVAL=2, a weekly rule needs to know where one week ends and the next begins in order to decide which weeks are the “other” ones. RFC 5545’s own example:

DTSTART:19970805T090000   (a Tuesday)
RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=MO
  → August 5, 10, 19, 24
RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=SU
  → August 5, 17, 19, 31

Same start, same days, different dates. With a Monday week start the Sunday after the first Tuesday is in the same week; with a Sunday week start it begins the next one, which is then skipped. If a series produced by one application lands differently in another, a missing or mismatched WKST is the first thing to check. Single-day weekly rules and rules with INTERVAL=1 are unaffected.

UNTIL, time zones and daylight saving

UNTIL must be the same kind of value as DTSTART. A date-only start takes a date-only UNTIL=20271231. A start with a time and a TZID takes an UNTIL in UTC, ending in Z. Mixing them is one of the more common reasons an importer rejects a file outright.

A recurring event pinned to a TZID keeps its wall-clock time through daylight saving changes: a 09:00 meeting stays at 09:00 in March. That only works if the file also carries a VTIMEZONE block describing that zone’s rules. A series written in UTC instead stays on the same instant, which moves by an hour on the local clock twice a year. That is why the recurring dates generator shows you the RRULE to copy but writes its downloadable .ics as one event per occurrence, each converted separately — every date lands on the right local time, with no hand-built time zone definition that an importer might reject.

Exceptions: EXDATE and RDATE

Real series have holes and extras. EXDATE lists occurrences to remove (the weekly meeting cancelled for a bank holiday) and RDATE lists extra ones to add. Both sit beside the RRULE in the same event rather than inside it, and an EXDATE must match the occurrence’s start exactly, time included, or it silently removes nothing.

Reading a rule you were sent

  1. Find FREQ and INTERVAL: that is the rhythm.
  2. Read the BY… parts as filters on that rhythm, then BYSETPOS as a final pick.
  3. Take anything unspecified — the time, the day of the month — from DTSTART.
  4. Check for COUNT or UNTIL; neither means forever.
  5. Ask whether any generated date could be impossible: the 29th to 31st, or 29 February.

To see the actual dates a rule produces, and which ones it skips, build it in the recurring dates generator. To send a single event rather than a series, the .ics file generator is the simpler tool, and the guide to .ics files covers what else goes in the file around the rule.

Put it to work

Free, no signup, and it runs in your browser — nothing you enter is uploaded.

More guides