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=10That pair means: ten Mondays, starting Monday 4 January 2027.
Every part, in plain English
| Part | Means | Example |
|---|---|---|
FREQ | The base period: DAILY, WEEKLY, MONTHLY, YEARLY (also HOURLY, MINUTELY, SECONDLY, which calendar apps rarely offer). | FREQ=MONTHLY |
INTERVAL | Repeat every n periods. Defaults to 1. | INTERVAL=2 — every other one |
BYDAY | Weekdays, 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 |
BYMONTHDAY | Days of the month, 1 to 31, or counted from the end with negatives: -1 is the last day. | BYMONTHDAY=15 |
BYMONTH | Months, 1 to 12. | BYMONTH=3,9 |
BYSETPOS | After 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 |
COUNT | Stop after this many occurrences. The DTSTART counts as the first. | COUNT=12 |
UNTIL | Stop after this date. Inclusive — an occurrence on the UNTIL date still happens. Never use it together with COUNT. | UNTIL=20271231 |
WKST | Which 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 want | RRULE |
|---|---|
| Every Monday | FREQ=WEEKLY;BYDAY=MO |
| Every weekday | FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR |
| Every other Tuesday and Thursday | FREQ=WEEKLY;INTERVAL=2;BYDAY=TU,TH |
| The 15th of every month | FREQ=MONTHLY;BYMONTHDAY=15 |
| The third Tuesday of every month | FREQ=MONTHLY;BYDAY=3TU |
| The last Friday of every month | FREQ=MONTHLY;BYDAY=-1FR |
| The last day of every month | FREQ=MONTHLY;BYMONTHDAY=-1 |
| The last working day of every month | FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1 |
| The first Monday of each quarter | FREQ=YEARLY;BYMONTH=1,4,7,10;BYDAY=1MO |
| US Thanksgiving | FREQ=YEARLY;BYMONTH=11;BYDAY=4TH |
| Daily for ten days | FREQ=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=6That 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, 31Same 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
- Find
FREQandINTERVAL: that is the rhythm. - Read the BY… parts as filters on that rhythm, then
BYSETPOSas a final pick. - Take anything unspecified — the time, the day of the month — from
DTSTART. - Check for
COUNTorUNTIL; neither means forever. - 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.