Back to blog
FILE 0x9C·MIGRATING FROM TODOIST WITH AN IDEMPOTENT IMPORT

Migrating from Todoist with an idempotent import

May 11, 2026 · migration, todoist

Migrated all my active Todoist tasks into my own todo backend. 27 tasks across 6 categories (birthdays, bills, dev backlog, groceries, issues for a personal project, trash days). The interesting part was making the import idempotent, so I could re-run it without creating duplicates.

The shape

For each Todoist task, write a row to the destination database with two extra fields:

On every import run, the inserter checks for an existing row with the same source_ref. If it exists, update it; if not, insert. No duplicates regardless of how many times the script runs.

This is the cheapest possible idempotency: the source's own ID, prefixed with the source name, as a natural key. Works for any migration where the source has stable IDs.

Recurrence translation

Todoist's recurrence DSL ("every Mon, Wed, Fri at 7am", "every month on the 15th") doesn't map cleanly to anything else. The target backend uses RFC 5545 RRULE strings — the calendar- standard format used by iCal, Google Calendar, and anything else that takes recurrence seriously.

A small translation layer parses Todoist's natural-language recurrence and emits an RRULE:

"every Mon, Wed, Fri at 7am" → RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR
"every month on the 15th"    → RRULE:FREQ=MONTHLY;BYMONTHDAY=15
"every year on Oct 11"       → RRULE:FREQ=YEARLY;BYMONTH=10;BYMONTHDAY=11

Birthdays got the yearly variant. Bills got weekly or monthly depending on cadence. Trash days got the WEEKLY+BYDAY variant.

Keeping Todoist intact

The Todoist account is still active. It's the verification source — if I notice an item that didn't make it across, I can check the original. Once I've been running on the new backend long enough to trust it (call it 60 days?), I'll archive Todoist.

This is the pattern for any migration where the cost of a missed item is real: don't shut off the source until the destination has been running independently long enough to prove it.

What I'd do differently

A dry-run mode would have helped. The first import worked, but I spent the next half-hour spot-checking that recurrence translations came out right. A --dry-run flag that prints "would insert X rows, would update Y, here are sample translations" before doing anything would've turned that spot-check into a single review pass before the write.

Idempotency made the cost of "run it again" basically free, so I got away with iterating. But for a future migration where the target is something I can't easily roll back, the dry-run is mandatory.