If you want a car in Singapore, you first need a Certificate of Entitlement (COE). They're sold in bidding rounds run by the Land Transport Authority (LTA), twice a month. The round that closed on 4 November 2026 ended at S$139,001 for Category A, so people watch the dates closely.
The dates look irregular. Rounds are usually two weeks apart, but sometimes three. Most run Monday to Wednesday, but some open on a Tuesday, and one closed on a Friday. When is the next COE bidding? You can't quite tell by looking.
I wanted to know how much of that is a real pattern. So I took all 72 rounds from 2024 to 2026 and tested a small rule against them. It fits all 72.
This post covers the rule, the test, and why our COE bidding dates page still refuses to use it.
The rule
Two lines:
- Round 1 starts from the first Monday of the month. Round 2 starts two weeks later.
- A round runs for three working days. Weekends and public holidays don't count. It opens at 12 noon on the first day and closes at 4 pm on the third.
In Python:
DAY = dt.timedelta(days=1)
def first_monday(year, month):
d = dt.date(year, month, 1)
return d + (7 - d.weekday()) % 7 * DAY
def working(d):
return d.weekday() < 5 and d not in HOLIDAYS
def window(year, month, n):
day = first_monday(year, month) + 14 * (n - 1) * DAY
days = []
while len(days) < 3:
if working(day):
days.append(day)
day += DAY
return days[0], days[-1]
The second line does all the work. "Three working days" sounds too simple to cover a Tuesday opening and a Friday closing, but it covers both without a special case.
The test
The test reads each round from our public calendar feed, which carries LTA's published schedule, and compares both the opening and closing day with the rule. The holidays are the Ministry of Manpower's lists, including the Mondays observed after a Sunday holiday.
| Check | Result |
|---|---|
| Rounds tested | 72 |
| Normal Monday to Wednesday | 66 |
| Moved by a holiday | 6 |
| Rounds the rule gets wrong | 0 |
Here are the 6 moved rounds, each one predicted correctly:
| Round | Holiday | Result |
|---|---|---|
| Jan 2024 (1) | Mon 1 Jan, New Year's Day | Tue to Thu |
| May 2024 (2) | Wed 22 May, Vesak Day | Mon to Thu |
| Jun 2024 (2) | Mon 17 Jun, Hari Raya Haji | Tue to Thu |
| Oct 2025 (2) | Mon 20 Oct, Deepavali | Tue to Thu |
| Feb 2026 (2) | Tue 17 and Wed 18 Feb, Chinese New Year | Mon to Fri |
| Jun 2026 (1) | Mon 1 Jun, observed for Vesak Day | Tue to Thu |
The reverse check matters too. A rule can pass by luck if it never meets a holiday it should have ignored, so I also listed every weekday holiday that lands on a normal Monday to Wednesday window. There are 7 such dates across those 6 rounds, and nothing else. No holiday fell in a window without moving it.
Why some rounds are three weeks apart
This one comes straight from the calendar. Round 2 opens on the third Monday. Round 1 of the next month opens on the first Monday. If the month has four Mondays, that gap is two weeks. If it has five, the gap is three.
def mondays(year, month):
d, n = first_monday(year, month), 0
while d.month == month:
d, n = d + 7 * DAY, n + 1
return n
There were 13 three-week gaps from 2024 to 2026, and all 13 follow a five-Monday month. The gaps between openings come out as:
| Days between openings | Times |
|---|---|
| 13 | 4 |
| 14 | 51 |
| 15 | 3 |
| 21 | 13 |
The 13s and 15s are holiday moves. A round that opens on a Tuesday sits one day closer to the next round and one day further from the last.
Why we still don't publish a prediction
LTA hadn't published its 2027 dates when I wrote this on 7 November 2026. The rule could fill the gap, and it would be easy to add. Our page doesn't do it, and I think that's right.
Three years of fit is a description of the past. It isn't LTA's stated rule. A holiday pattern outside these 72 rounds could behave differently, and a holiday can be declared at short notice, as Polling Day was in 2025. People plan car purchases and renewals around these dates, and a wrong date is worse than a missing one.
So the rule is useful as a check instead. When a new schedule lands, you can run it through the rule and look hard at any round that doesn't fit, which catches typing errors in a table that gets kept up once a year.
If you're building something that needs dates ahead of the published schedule, label them as estimates.
The calendar feed
The dates matter most when they turn up in your calendar without you doing anything, so the page has a subscribable .ics feed. Generating iCalendar by hand has a few traps:
Keep the UID stable. Each round has a UID built from its month and round number, such as coe-2026-11-2@numbers.sg. That's how a calendar app knows an event is the same one when it changes. If you generate UIDs from anything else, subscribers get duplicates.
Store times in UTC. 12 noon in Singapore is 040000Z, so a subscriber in another time zone sees the right moment.
Escape the commas. iCalendar treats commas as separators in text values, so S$139,001 has to go out as $139\,001. It's easy to miss, because the premium only appears after a round closes.
Fold lines at 75 octets, not 75 characters. RFC 5545 counts bytes. A curly apostrophe is three bytes in UTF-8, and if you cut through the middle of one you get a broken character:
while ( strlen( $l ) > 74 ) {
$cut = 74;
while ( $cut > 0 && ( ord( $l[ $cut ] ) & 0xC0 ) === 0x80 ) {
--$cut; // step back off a UTF-8 continuation byte
}
$o .= substr( $l, 0, $cut ) . "\r\n ";
$l = substr( $l, $cut );
}
Say how often to refresh, and expect to be ignored. The feed sets REFRESH-INTERVAL;VALUE=DURATION:P1D and X-PUBLISHED-TTL:P1D. These are requests, not orders. Google Calendar decides for itself how often to refresh a subscribed calendar, so a change can take a while to show there.
A nice side effect of a stable UID is that the event can gain information later. Once a round's results reach LTA's data on data.gov.sg, the feed adds the premiums to that event, so your calendar slowly turns into a price history. The latest figures for every category are on the COE results page.
Limits
- The test covers 2024 to 2026, which is the span of LTA's schedules in our data. Older years aren't tested.
- The rule is my reading of the published dates. LTA's schedule is the only authority.
- Holidays are hard-coded in the script for those three years, from MOM's lists.
Numbers.sg reads every figure from the agency that publishes it. The update schedule shows when each one is checked next, and about Numbers.sg covers who runs the site.
AI assistants helped draft this post and write the test. The results come from the script below, run on 7 November 2026.
The full test script (stdlib only)
#!/usr/bin/env python3
"""Test a two-line rule against every COE bidding round from 2024 to 2026.
Rounds come from the public Numbers.sg calendar feed, which carries LTA's published schedule.
Holidays are MOM's lists, including Mondays observed after a Sunday holiday. Standard library only."""
import collections, datetime as dt, re, urllib.request
FEED = "https://numbers.sg/data/coe-bidding-dates.ics"
DAY = dt.timedelta(days=1)
HOLIDAYS = {dt.date.fromisoformat(d) for d in """
2024-01-01 2024-02-10 2024-02-11 2024-02-12 2024-03-29 2024-04-10 2024-05-01 2024-05-22 2024-06-17 2024-08-09
2024-10-31 2024-12-25
2025-01-01 2025-01-29 2025-01-30 2025-03-31 2025-04-18 2025-05-01 2025-05-03 2025-05-12 2025-06-07 2025-08-09
2025-10-20 2025-12-25
2026-01-01 2026-02-17 2026-02-18 2026-03-21 2026-04-03 2026-05-01 2026-05-27 2026-05-31 2026-06-01 2026-08-09
2026-08-10 2026-11-08 2026-11-09 2026-12-25
""".split()}
def rounds():
req = urllib.request.Request(FEED, headers={"User-Agent": "coe-rule-test/1.0"})
ics = urllib.request.urlopen(req, timeout=30).read().decode().replace("\r\n ", "")
for ev in re.findall(r"BEGIN:VEVENT(.*?)END:VEVENT", ics, re.S):
tag = re.search(r"UID:coe-(\d{4}-\d{2})-(\d)@", ev)
o = re.search(r"DTSTART:(\d{8})", ev)[1]
c = re.search(r"DTEND:(\d{8})", ev)[1]
yield tag[1], int(tag[2]), dt.datetime.strptime(o, "%Y%m%d").date(), dt.datetime.strptime(c, "%Y%m%d").date()
def first_monday(year, month):
d = dt.date(year, month, 1)
return d + (7 - d.weekday()) % 7 * DAY
def working(d):
return d.weekday() < 5 and d not in HOLIDAYS
def window(year, month, n):
"""Three working days, starting at or after the first Monday (round 1) or two weeks later (round 2)."""
day = first_monday(year, month) + 14 * (n - 1) * DAY
days = []
while len(days) < 3:
if working(day):
days.append(day)
day += DAY
return days[0], days[-1]
def mondays(year, month):
d, n = first_monday(year, month), 0
while d.month == month:
d, n = d + 7 * DAY, n + 1
return n
R = sorted(rounds(), key=lambda r: r[2])
plain = sum(1 for m, n, o, c in R if o == first_monday(int(m[:4]), int(m[5:])) + 14 * (n - 1) * DAY and c == o + 2 * DAY)
miss = [(m, n, o, c, window(int(m[:4]), int(m[5:]), n)) for m, n, o, c in R if (o, c) != window(int(m[:4]), int(m[5:]), n)]
print(len(R), "rounds,", R[0][0], "to", R[-1][0])
print(plain, "open Monday and close Wednesday on the first and third Monday pattern")
print(len(R) - len(miss), "match the three-working-days rule;", len(miss), "miss:", miss)
gaps = collections.Counter()
after_five = 0
for a, b in zip(R, R[1:]):
g = (b[2] - a[2]).days
gaps[g] += 1
if g == 21 and mondays(int(a[0][:4]), int(a[0][5:])) == 5:
after_five += 1
print("gaps between openings:", sorted(gaps.items()))
print(after_five, "of", gaps[21], "three-week gaps follow a five-Monday month")
inside = [d for d in sorted(HOLIDAYS) if d.weekday() < 5 and any(o <= d <= c for _, _, o, c in R)]
print("weekday holidays inside a window:", inside)
near = [d for d in sorted(HOLIDAYS) if d.weekday() < 3 and any(first_monday(d.year, d.month) + k * DAY <= d <= first_monday(d.year, d.month) + (k + 2) * DAY for k in (0, 14))]
print("weekday holidays that land on a normal Mon to Wed window:", near)
Top comments (0)