Table of Contents

RFC 5545 recurrence rules

RecurrenceRule is an immutable RFC 5545 RRULE: a base FREQ refined by INTERVAL, bounded by COUNT or UNTIL, and filtered by the BY* rule parts. The rule carries no start of its own - the series start (DTSTART) is passed to every occurrence query - so one parsed rule can be applied to any number of series. It parses from text, renders back to canonical text, compares by value, and answers GetNextOccurrence / GetPreviousOccurrence over both DateTime and DateTimeOffset.

This page is the per-form reference. For the "which form" decision and the due-ness recipe every form shares, start at the recurrence overview; for the vocabulary (occurrence, inclusive boundary, frequency period) see Core concepts.

Note

Occurrence enumeration supports DAILY, WEEKLY, MONTHLY, and YEARLY. A rule with a sub-daily frequency (SECONDLY, MINUTELY, HOURLY) still parses and round-trips, but every occurrence query on it throws NotSupportedException - the message reads "The recurrence frequency 'Hourly' is not supported; sub-daily frequencies are a planned follow-on." Time-of-day within a daily-or-coarser rule is fully supported through BYHOUR / BYMINUTE / BYSECOND.

Pattern 1 - parse, inspect, and format

Parse accepts the bare rule text or the text with its RRULE: property prefix, in any case. The typed parts expose every component; the BY* lists are IReadOnlyList<int> (or IReadOnlyList<WeekDayNum> for BYDAY) and are empty when the part is absent.

using Bodu.Globalization.Recurrence;

RecurrenceRule rule = RecurrenceRule.Parse("RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=TU;WKST=SU;COUNT=5");

RecurrenceFrequency frequency = rule.Frequency;   // Weekly
int interval                  = rule.Interval;    // 2
int? count                    = rule.Count;       // 5
DateTime? until               = rule.Until;       // null - bounded by COUNT instead
DayOfWeek weekStart           = rule.WeekStart;   // Sunday
WeekDayNum tuesday            = rule.ByDay[0];    // Ordinal = 0, Day = Tuesday

string canonical = rule.ToString();
// "FREQ=WEEKLY;INTERVAL=2;COUNT=5;BYDAY=TU;WKST=SU"

ToString() renders the canonical form, which re-parses to an equal rule: parts in RFC 5545 order (FREQ, INTERVAL, COUNT, UNTIL, BYSECOND, BYMINUTE, BYHOUR, BYDAY, BYMONTHDAY, BYYEARDAY, BYWEEKNO, BYMONTH, BYSETPOS, WKST), with INTERVAL=1 and WKST=MO omitted because they are the defaults, upper-case tokens, and no RRULE: prefix. byday=fr;freq=monthly;bysetpos=-2 therefore renders as FREQ=MONTHLY;BYDAY=FR;BYSETPOS=-2.

The type implements IFormattable, but only the general specifier is defined: ToString("G") and ToString(null) return the canonical text and any other specifier throws FormatException ("The format string 'X' is not supported."). The IFormatProvider argument is ignored - rule text is culture-invariant.

UNTIL keeps the kind it was parsed with: UNTIL=20260131T000000Z yields a DateTimeKind.Utc value and renders with the Z; a floating UNTIL=20260131T000000 (or a date-only UNTIL=20260131, which is read as midnight) stays Unspecified and renders without it.

Pattern 2 - validate configuration with TryParse

Three TryParse shapes exist. The IParsable<TSelf> / ISpanParsable<TSelf> overloads answer a boolean; the third adds an out string? failureMessage that names the defect, which is the one to use when the rule text comes from a user or a configuration file:

using Bodu.Globalization.Recurrence;

string text = "FREQ=DAILY;COUNT=3;UNTIL=20260101T000000Z";

if (!RecurrenceRule.TryParse(text, out RecurrenceRule? rule, out string? failureMessage))
{
    Console.WriteLine(failureMessage);
    // "The COUNT and UNTIL rule parts cannot both appear in the same rule."
}

// The boolean-only overloads, for callers that do not need the reason:
bool ok1 = RecurrenceRule.TryParse("FREQ=DAILY", out RecurrenceRule? r1);
bool ok2 = RecurrenceRule.TryParse("FREQ=DAILY".AsSpan(), provider: null, out RecurrenceRule? r2);

Other messages you will see verbatim: a missing frequency ("A recurrence rule requires a frequency (FREQ) component."), a repeated part ("The recurrence-rule component 'FREQ' appears more than once."), a value out of range ("The recurrence-rule component 'INTERVAL=0' is not valid."), and empty input ("The recurrence-rule text is empty or contains only white space."). Parse throws FormatException with the same message, and ArgumentNullException for a null string.

Pattern 3 - build a rule fluently

RecurrenceRuleBuilder is the code-first alternative to text. It is constructed with the frequency; every other method returns the same builder so calls chain, and Build() produces the immutable rule. The builder can be reused after Build(), and each By* call replaces the values previously supplied for that part rather than appending.

using Bodu.Globalization.Recurrence;

RecurrenceRule lastFriday = new RecurrenceRuleBuilder(RecurrenceFrequency.Monthly)
    .WithInterval(1)
    .WithCount(12)
    .ByDay(new WeekDayNum(-1, DayOfWeek.Friday))       // "-1FR": the last Friday of each month
    .Build();
// FREQ=MONTHLY;COUNT=12;BYDAY=-1FR

RecurrenceRule weekdays = new RecurrenceRuleBuilder(RecurrenceFrequency.Weekly)
    .ByDay(DayOfWeek.Monday, DayOfWeek.Friday)         // plain DayOfWeek values: every Monday and Friday
    .WithWeekStart(DayOfWeek.Sunday)
    .WithUntil(new DateTime(2026, 12, 31, 0, 0, 0, DateTimeKind.Utc))
    .Build();
// FREQ=WEEKLY;UNTIL=20261231T000000Z;BYDAY=MO,FR;WKST=SU

bool same = lastFriday.Equals(RecurrenceRule.Parse("FREQ=MONTHLY;COUNT=12;BYDAY=-1FR"));   // true
Method Rule part Accepted values
WithInterval(int) INTERVAL ≥ 1
WithCount(int) COUNT ≥ 1; clears any UNTIL
WithUntil(DateTime) UNTIL any instant; clears any COUNT
WithWeekStart(DayOfWeek) WKST any day; default Monday
BySecond(params int[]) BYSECOND 0-60
ByMinute(params int[]) BYMINUTE 0-59
ByHour(params int[]) BYHOUR 0-23
ByDay(params WeekDayNum[]) BYDAY ordinal ±1…±53 or 0 for every occurrence
ByDay(params DayOfWeek[]) BYDAY convenience for ordinal 0
ByMonthDay(params int[]) BYMONTHDAY ±1…±31, never 0
ByYearDay(params int[]) BYYEARDAY ±1…±366, never 0
ByWeekNo(params int[]) BYWEEKNO ±1…±53, never 0
ByMonth(params int[]) BYMONTH 1-12
BySetPos(params int[]) BYSETPOS ±1…±366, never 0

WithCount and WithUntil are mutually exclusive: supplying one clears the other, matching the RFC rule that COUNT and UNTIL cannot both appear. Out-of-range values throw ArgumentOutOfRangeException at the call, not at Build().

WeekDayNum is a readonly record struct (int Ordinal, DayOfWeek Day). Ordinal 0 means every occurrence of the day (IsEveryOccurrence is true); a positive ordinal counts from the start of the frequency period and a negative one from its end, so 2TU is the second Tuesday of the month in a MONTHLY rule and the second Tuesday of the year in a YEARLY rule. A + sign is accepted on input (+1MO) and dropped on output (1MO).

Pattern 4 - enumerate occurrences

GetOccurrences(start) yields the series in ascending order, each value preserving the Kind of start. The sequence is bounded when the rule declares COUNT or UNTIL; otherwise it continues to the end of the representable calendar, so bound it with Take or use the windowed overload. Membership depends only on the rule and the start - never on a window - so the two overloads always agree.

using Bodu.Globalization.Recurrence;

var start = new DateTime(2026, 1, 5, 9, 0, 0);          // Monday 09:00

RecurrenceRule bounded = RecurrenceRule.Parse("FREQ=DAILY;COUNT=3");
DateTime[] three = bounded.GetOccurrences(start).ToArray();
// 2026-01-05 09:00, 2026-01-06 09:00, 2026-01-07 09:00

RecurrenceRule mondays = RecurrenceRule.Parse("FREQ=WEEKLY;BYDAY=MO");
DateTime[] firstFive = mondays.GetOccurrences(start).Take(5).ToArray();   // unbounded: Take is required

DateTime[] march = mondays
    .GetOccurrences(start, from: new DateTime(2026, 3, 1), to: new DateTime(2026, 3, 31))
    .ToArray();
// 2026-03-02, 03-09, 03-16, 03-23, 03-30 - the inclusive [from, to] window

The start instant is emitted only when it satisfies the rule. FREQ=WEEKLY;BYDAY=WE anchored on Monday 5 January 2026 begins on Wednesday 7 January; nothing is added implicitly. Time-of-day comes from the start unless a BY* time part overrides it: FREQ=DAILY;BYHOUR=9,17;BYMINUTE=30 from 2026-01-05 09:00 yields 09:30 and 17:30 that day, and FREQ=DAILY;BYHOUR=2 from 2026-01-01 09:30 yields its first occurrence at 2026-01-02 02:30, because 02:30 on 1 January precedes the start.

Pattern 5 - point queries with inclusive flags

GetNextOccurrence(start, after, inclusive) and GetPreviousOccurrence(start, before, inclusive) answer a single instant, or null. With inclusive: false (the default) the boundary instant is excluded; with inclusive: true an occurrence exactly equal to the boundary is returned.

using Bodu.Globalization.Recurrence;

var start = new DateTime(2026, 1, 5, 9, 0, 0);
RecurrenceRule mondays = RecurrenceRule.Parse("FREQ=WEEKLY;BYDAY=MO");
var at = new DateTime(2026, 1, 12, 9, 0, 0);              // exactly an occurrence

DateTime? nextExclusive = mondays.GetNextOccurrence(start, at);                      // 2026-01-19 09:00
DateTime? nextInclusive = mondays.GetNextOccurrence(start, at, inclusive: true);     // 2026-01-12 09:00
DateTime? prevExclusive = mondays.GetPreviousOccurrence(start, at);                  // 2026-01-05 09:00
DateTime? prevInclusive = mondays.GetPreviousOccurrence(start, at, inclusive: true); // 2026-01-12 09:00

DateTime? nothing = mondays.GetPreviousOccurrence(start, new DateTime(2025, 1, 1));  // null - before the series
DateTime? never   = RecurrenceRule.Parse("FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=30")
    .GetNextOccurrence(start, start);                                                // null - 30 February never exists

Both searches are bounded by the end of the representable calendar (year 9999), so a rule that can never match answers null rather than scanning forever.

The DateTimeOffset overloads expand the rule on the wall-clock time of the start and reattach the start's offset to every result; the library performs no offset conversion and never consults the machine time zone. A start of 2026-01-05 09:00 +10:00 with FREQ=DAILY answers 2026-01-06 09:00 +10:00 for any after on 5 January, whatever offset after carries:

using Bodu.Globalization.Recurrence;

var start = new DateTimeOffset(2026, 1, 5, 9, 0, 0, TimeSpan.FromHours(10));
var afterUtc = new DateTimeOffset(2026, 1, 5, 0, 0, 0, TimeSpan.Zero);

DateTimeOffset? next = RecurrenceRule.Parse("FREQ=DAILY").GetNextOccurrence(start, afterUtc);
// 2026-01-06 09:00 +10:00

Daylight-saving transitions are the caller's concern - see Hosting schedules for converting at the host boundary.

Pattern 6 - last working day of the month

BYDAY lists the weekdays; BYSETPOS=-1 selects the last candidate in each monthly period:

using Bodu.Globalization.Recurrence;

RecurrenceRule lastWorkingDay = RecurrenceRule.Parse("FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1");

DateTime[] firstHalf2026 = lastWorkingDay.GetOccurrences(new DateTime(2026, 1, 1)).Take(6).ToArray();
// 2026-01-30 Fri, 2026-02-27 Fri, 2026-03-31 Tue, 2026-04-30 Thu, 2026-05-29 Fri, 2026-06-30 Tue

"Working day" here means Monday-Friday only. To also skip public holidays, filter the stream with IsNonWorkingDay from Bodu.Globalization.Calendar - see Hosting schedules.

Pattern 7 - every second Tuesday

INTERVAL multiplies the frequency period. Anchored on a Tuesday, FREQ=WEEKLY;INTERVAL=2;BYDAY=TU is every fourteenth day:

using Bodu.Globalization.Recurrence;

RecurrenceRule fortnightly = RecurrenceRule.Parse("FREQ=WEEKLY;INTERVAL=2;BYDAY=TU");

DateTime[] tuesdays = fortnightly.GetOccurrences(new DateTime(2026, 1, 6)).Take(5).ToArray();
// 2026-01-06, 2026-01-20, 2026-02-03, 2026-02-17, 2026-03-03

Which weeks count as "every second" depends on WKST - see How WKST changes the answer below.

Pattern 8 - the second-to-last Friday

Negative BYSETPOS values index from the end of the candidate set of each period:

using Bodu.Globalization.Recurrence;

RecurrenceRule penultimateFriday = RecurrenceRule.Parse("FREQ=MONTHLY;BYDAY=FR;BYSETPOS=-2");

DateTime[] fridays = penultimateFriday.GetOccurrences(new DateTime(2026, 1, 1)).Take(4).ToArray();
// 2026-01-23, 2026-02-20, 2026-03-20, 2026-04-17

FREQ=MONTHLY;BYDAY=-2FR (an ordinal on the BYDAY entry) produces the same dates; BYSETPOS is the form to reach for when the candidate set mixes several weekdays.

Pattern 9 - a COUNT-bounded series

COUNT caps the number of occurrences after deduplication and BYSETPOS selection, so it counts emitted instants, not candidates. A bounded rule's enumeration terminates on its own:

using Bodu.Globalization.Recurrence;

RecurrenceRule twelveSessions = new RecurrenceRuleBuilder(RecurrenceFrequency.Weekly)
    .ByDay(DayOfWeek.Wednesday)
    .WithCount(12)
    .Build();

var start = new DateTime(2026, 2, 4, 18, 0, 0);
int total       = twelveSessions.GetOccurrences(start).Count();                       // 12
DateTime? last  = twelveSessions.GetPreviousOccurrence(start, DateTime.MaxValue);      // the twelfth Wednesday
DateTime? after = twelveSessions.GetNextOccurrence(start, last!.Value);                // null - the series is exhausted

Pattern 10 - catching up after downtime

Because the library stores no last-run state, a catch-up is a windowed enumeration from the last recorded run to the resume instant. The window is inclusive at both ends, so exclude the last run itself if it was completed:

using Bodu.Globalization.Recurrence;

RecurrenceRule nightly = RecurrenceRule.Parse("FREQ=DAILY;BYHOUR=2;BYMINUTE=0");
var seriesStart = new DateTime(2026, 1, 1, 2, 0, 0);

var lastRun   = new DateTime(2026, 3, 9, 18, 0, 0);     // persisted before the host stopped
var resumedAt = new DateTime(2026, 3, 12, 14, 32, 0);   // supplied by the host on restart

DateTime[] missed = nightly
    .GetOccurrences(seriesStart, from: lastRun, to: resumedAt)
    .Where(occurrence => occurrence > lastRun)
    .ToArray();
// 2026-03-10 02:00, 2026-03-11 02:00, 2026-03-12 02:00

// Or coalesce the backlog into a single "is anything due?" decision:
bool isDue = lastRun < nightly.GetPreviousOccurrence(seriesStart, resumedAt, inclusive: true);   // true

The Hosting schedules guide turns this into a reproducible loop over a TimeProvider.

Where implementations disagree

Recurrence libraries diverge on a handful of BY* interactions. Each statement below is what this library does, with the output the sample produced.

Invalid generated dates are skipped, never clamped. FREQ=MONTHLY from 31 January yields 31 March, 31 May, 31 July, 31 August - months without a 31st are omitted rather than rolled back to the 30th. RFC 5545 requires this; it is also the single most common false bug report against recurrence libraries.

using Bodu.Globalization.Recurrence;

DateTime[] thirtyFirsts = RecurrenceRule.Parse("FREQ=MONTHLY")
    .GetOccurrences(new DateTime(2026, 1, 31)).Take(5).ToArray();
// 2026-01-31, 2026-03-31, 2026-05-31, 2026-07-31, 2026-08-31

The occurrence set is a set. Two BY values resolving to the same date contribute one occurrence, and deduplication happens before BYSETPOS indexes the candidates and before COUNT counts them. BYMONTHDAY=1,-31 yields a single 1 January in 2026, not two:

using Bodu.Globalization.Recurrence;

DateTime[] firsts = RecurrenceRule.Parse("FREQ=MONTHLY;BYMONTHDAY=1,-31")
    .GetOccurrences(new DateTime(2026, 1, 1)).Take(3).ToArray();
// 2026-01-01, 2026-02-01, 2026-03-01

BYSETPOS indexes the whole frequency period, including candidates that precede the series start; those are dropped only afterwards. FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=1 anchored on Wednesday 7 January 2026 selects the Monday of each week - the first candidate of the period - so its first occurrence is Monday 12 January, not the Wednesday it was anchored on:

using Bodu.Globalization.Recurrence;

DateTime[] firstWeekday = RecurrenceRule.Parse("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=1")
    .GetOccurrences(new DateTime(2026, 1, 7)).Take(3).ToArray();
// 2026-01-12 Mon, 2026-01-19 Mon, 2026-01-26 Mon

A BY filter never re-anchors an interval. FREQ=DAILY;INTERVAL=14;BYMONTH=10,12 counts every fourteenth day from the start unconditionally and drops the ones outside October and December; the cadence is not restarted at 1 October:

using Bodu.Globalization.Recurrence;

DateTime[] fortnightlyInQ4 = RecurrenceRule.Parse("FREQ=DAILY;INTERVAL=14;BYMONTH=10,12")
    .GetOccurrences(new DateTime(2026, 9, 1)).Take(4).ToArray();
// 2026-10-13, 2026-10-27, 2026-12-08, 2026-12-22

Ordinals in BYDAY count within the frequency period. FREQ=MONTHLY;BYDAY=2TU is the second Tuesday of each month (13 January, 10 February, 10 March 2026); FREQ=YEARLY;BYDAY=20MO is the twentieth Monday of the year (18 May 2026). FREQ=YEARLY;BYMONTH=11;BYDAY=4TH narrows the yearly period to November first, giving the fourth Thursday of November (26 November 2026 - US Thanksgiving). BYYEARDAY=1,100,-1 selects 1 January, 10 April, and 31 December.

How WKST changes the answer

WKST (default Monday) reparameterises week numbering, not just weekly intervals: it decides which dates BYWEEKNO resolves to and which weeks an INTERVAL counts. Numbered weeks straddle the calendar year, so week 1 may begin in the preceding December.

using Bodu.Globalization.Recurrence;

// BYWEEKNO=1 with Monday weeks: week 1 of 2026 begins Monday 29 December 2025 - before the
// series start - so the first hit is the Monday of week 1 in 2027.
DateTime[] isoWeekOne = RecurrenceRule.Parse("FREQ=YEARLY;BYWEEKNO=1;BYDAY=MO")
    .GetOccurrences(new DateTime(2026, 1, 1)).Take(2).ToArray();
// 2027-01-04, 2028-01-03

// With Sunday weeks, week 1 of 2026 begins Sunday 4 January, so its Monday is 5 January 2026.
DateTime[] sundayWeekOne = RecurrenceRule.Parse("FREQ=YEARLY;BYWEEKNO=1;BYDAY=MO;WKST=SU")
    .GetOccurrences(new DateTime(2026, 1, 1)).Take(2).ToArray();
// 2026-01-05, 2027-01-04

// The same fortnightly rule from Sunday 4 January 2026 pairs different Tuesdays with each Sunday:
DateTime[] mondayWeeks = RecurrenceRule.Parse("FREQ=WEEKLY;INTERVAL=2;BYDAY=TU,SU")
    .GetOccurrences(new DateTime(2026, 1, 4)).Take(4).ToArray();
// 2026-01-04 Sun, 2026-01-13 Tue, 2026-01-18 Sun, 2026-01-27 Tue   (Sunday closes a Mon-Sun week)

DateTime[] sundayWeeks = RecurrenceRule.Parse("FREQ=WEEKLY;INTERVAL=2;BYDAY=TU,SU;WKST=SU")
    .GetOccurrences(new DateTime(2026, 1, 4)).Take(4).ToArray();
// 2026-01-04 Sun, 2026-01-06 Tue, 2026-01-18 Sun, 2026-01-20 Tue   (Sunday opens a Sun-Sat week)

The builder's WithWeekStart sets the same part.

Value equality

RecurrenceRule implements IEquatable<T>: two rules are equal when every component - frequency, interval, count, until, week start, and every BY* list in source order - is equal. Parse(rule.ToString()) is always equal to rule, so persisting the canonical text and comparing on reload is a reliable change detector. Note that BYDAY=MO,FR and BYDAY=FR,MO produce identical occurrences but are not equal values; canonicalise the text with ToString() before comparing if source order should not matter. GetHashCode is consistent with Equals.

API summary

Member Purpose
Parse(string) / Parse(string, IFormatProvider?) / Parse(ReadOnlySpan<char>, IFormatProvider?) Parse; throws FormatException on a defect. Accepts an optional RRULE: prefix.
TryParse(string?, out rule) / TryParse(string?, IFormatProvider?, out rule) / TryParse(ReadOnlySpan<char>, IFormatProvider?, out rule) Boolean parse.
TryParse(string?, out rule, out string? failureMessage) Boolean parse that names the defect.
Frequency, Interval, Count, Until, WeekStart The scalar parts.
BySecond, ByMinute, ByHour, ByDay, ByMonthDay, ByYearDay, ByWeekNo, ByMonth, BySetPos The list parts, empty when absent.
GetOccurrences(start) / GetOccurrences(start, from, to) Lazy ascending enumeration; DateTime and DateTimeOffset overloads.
GetNextOccurrence(start, after, inclusive = false) First occurrence after (or at) after, or null.
GetPreviousOccurrence(start, before, inclusive = false) Last occurrence before (or at) before, or null.
ToString() / ToString(string?) / ToString(string?, IFormatProvider?) Canonical RFC 5545 text; only the "G" specifier is defined.
Equals / GetHashCode Component-wise value equality.

Where to go next