Skip to main content

The one about migrating notes from Turtl to Notion

Updated 10 min readMarkdown
Part of seriesTIL
The one about migrating notes from Turtl to Notion

For years, I have been using Turtl just for my travel notes. When I first started using that app, it was good enough to keep track of every country or city I wanted to visit. My notes include details about where to stay, what to do, where to eat, and where to go in every place. The different spaces were shared with my partner, so both of us could add details at any moment.

I never quite liked the UI provided by Turtl, so I decided it was time to move everything to Notion, that in my opinion provides a far better user experience and nicer UI. With the added benefit that I already have the rest of my notes in Notion, since I migrated them from Evernote a few years ago.

The problem was that Notion has no Turtl importer. All I had was a JSON backup exported from Turtl and a very clear idea of how I wanted the result to look:

Travels
├── Gran Canaria
│   ├── Food
│   │   └── <notes>
│   ├── Beaches
│   └── ...
├── Lanzarote
├── Fuerteventura
└── ...

I did not want to copy hundreds of notes by hand. So I opened Claude Code, described what I wanted, and worked through it with the agent step by step.

Step 1: Understand the backup

Before writing a single line of code, Claude inspected the backup file to see what was actually inside. A Turtl backup is a single JSON file with this shape:

{
  "spaces": [{ "id": "...", "title": "Gran Canaria" }],
  "boards": [{ "id": "...", "space_id": "...", "title": "Beaches" }],
  "notes":  [{ "id": "...", "space_id": "...", "board_id": "...",
               "type": "link", "title": "...", "url": "...",
               "text": "...", "tags": ["..."] }],
  "files":  [{ "id": "<note id>", "data": "<base64 image>" }]
}

That mapped very naturally onto my idea:

Turtl Notion Count
Space Place page 28
Board Category page 59
Note Note page 587

Looking at the data before planning revealed some details I was not aware of:

  • Note types: 401 notes were links, 184 were plain text and 2 were images.
  • Image notes: the images live in a separate files array, matched to their note by the note's ID.
  • Tags: only 47 notes had tags.
  • No category: 106 notes did not belong to any board.
  • Formatting: the note text is light Markdown.

At this point, Claude asked me two questions before planning anything:

  • How should the tags look in Notion?
  • Where should the uncategorized notes go?

We will come back to both.

Step 2: Create a Notion integration

First of all, we have to create a new page that will be used as the root page for the import process. In my case, the page is called Travels.

To create pages through the Notion API, you need a new connection.

Go to https://app.notion.com/developers/connections and click on New connection.

Image

Add the connection name and select API token as authentication method.

Create the connection.

Image

Now copy the generated token and save it in your .zshrc file, or the corresponding file for the shell you are using.

In the Content access tab, click on Edit access and select the page created at the beginning:

Image

In my case, the root page is already shared. You will find yours in the private section.

Once the migration is done, delete the integration from Notion and the token from your configuration.

Step 3: The script

The script is a single Python file that uses only the standard library (urllib, json, base64, re), so it runs on any machine with Python 3. You can find the full version here.

These are the parts I found most interesting.

Notion has limits you need to respect

The Notion API has a few constraints that shaped the code:

  • Page size: a page can be created with at most 100 blocks, so longer notes get the rest appended in batches.
  • Text length: a single rich-text item can hold at most 2000 characters, so long paragraphs are split into chunks.
  • Rate limit: the API allows about 3 requests per second, and it answers with a 429 and a Retry-After header when you go too fast.

The request helper handles all of this, plus network failures, which I will come back to:

for attempt in range(6):
    time.sleep(0.34)
    req = urllib.request.Request(f"{API}{path}", data=data, method=method, headers=headers)
    try:
        with urllib.request.urlopen(req, timeout=60) as resp:
            return json.loads(resp.read() or b"{}")
    except urllib.error.HTTPError as e:
        detail = e.read().decode(errors="replace")
        if (e.code == 429 or e.code >= 500) and attempt < 5:
            time.sleep(int(e.headers.get("Retry-After") or 2))
            continue
        raise RuntimeError(f"{method} {path} -> HTTP {e.code}: {detail}") from None
    except OSError:
        if attempt == 5:
            raise
        time.sleep(2)

Uploading images

The two image notes required Notion's File Upload API, which works in two steps:

  1. Create an upload.
  2. Send the bytes as multipart/form-data.

Without a library like requests, that means building the multipart body by hand. It is less scary than it sounds:

up = self.request("POST", "/file_uploads", {"filename": name, "content_type": mime})
boundary = uuid.uuid4().hex
body = (
    f"--{boundary}\r\n"
    f'Content-Disposition: form-data; name="file"; filename="{name}"\r\n'
    f"Content-Type: {mime}\r\n\r\n"
).encode() + raw + f"\r\n--{boundary}--\r\n".encode()
self.request("POST", f"/file_uploads/{up['id']}/send", data=body,
             content_type=f"multipart/form-data; boundary={boundary}")

The returned upload ID is then used in an image block.

Making it safe to run twice

Every time a page is created, the script saves the Turtl ID → Notion page ID mapping to a small turtl_notion_state.json file. If the run stops halfway, running it again skips everything already imported instead of creating duplicates. As a bonus, that file later made it possible to update existing pages in place.

Step 4: Practice before the real thing

The script needs the ID of the root page. It is the 32-character hexadecimal string at the end of the page URL:

https://www.notion.so/Travels-1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d
                             └──────────── page ID ─────────┘

Before running the import, I asked to practice with a single place first. The script got two flags for that:

# Prints the tree without touching Notion
python turtl_to_notion.py --dry-run turtl-backup.json

# Imports a single place
NOTION_PARENT_PAGE_ID=<Travels page ID> python turtl_to_notion.py --place Lanzarote turtl-backup.json

Lanzarote was a good test because it had a bit of everything: multiple categories, uncategorized notes, links and both image notes.

The dry run confirmed the counts (28 places, 59 categories, 587 notes), and the practice run created 78 pages. Seeing them in Notion made me change my mind about something. I had chosen to put uncategorized notes directly under each place, but next to the category pages they looked lost. So the rule became:

  • The place has categories: the uncategorized notes go into an Others page.
  • It has no categories: they stay directly under the place.

I deleted the test pages, reran Lanzarote, liked the result, and launched the full import: 690 pages in total, without a single error.

Step 5: The details you only notice afterwards

This is where working with an agent felt most like pair programming. I browsed the result, pointed at something that looked wrong, and we fixed it.

Many of my text notes had a URL pasted on its own line. Turtl turned those into links automatically, but the script only converted Markdown links like [text](url), so in Notion they were plain text. The same happened with __bold__ text, which showed up with its underscores.

The fix was a single regex that recognizes Markdown links, bare URLs, bold and italic:

INLINE_RE = re.compile(
    r"\[([^\]]+)\]\((https?://[^)\s]+)\)"
    r"|(https?://[^\s<>()]*[^\s<>().,;:!?'\"])"
    r"|(\*\*|__)(.+?)\4"
    r"|(?<![*_\w])([*_])(?=\S)(.+?)(?<=\S)\6(?![*_\w])"
)

Two details matter here:

  • Trailing punctuation: the URL pattern refuses to end with it, so https://example.com. at the end of a sentence does not swallow the dot.
  • Underscores inside words: the italic pattern requires non-word characters around the markers, so names like snake_case_name stay untouched.

Link notes had their URL in a dedicated field, which I had imported as a Notion bookmark (the big card with a preview). Text notes now had inline links. Seeing both side by side, I preferred the simpler inline version, so every note now shows its URL as a plain clickable line at the top.

Six URLs had been saved without a scheme, like turismolanzarote.com/, so the script now adds https:// when the value looks like a domain.

Tags

Remember the question about tags? Notion's native tags are a multi-select property, and properties only exist on pages inside a database. A plain page only has a title. That is why tags were initially imported as a simple Tags: a, b, c text line.

Before deciding whether to switch to databases, I asked where those 47 tagged notes actually were. 30 of them were in Lanzarote, and the rest were spread over 4 other places. Rebuilding everything as databases for 8% of the notes was not worth it, so I just removed the tags.

Updating pages in place

All these fixes affected pages that already existed, and I did not want to delete and recreate them, which would have changed their URLs and their order. The state file made it possible to update them in place:

  1. List the page's blocks.
  2. Delete them.
  3. Append the new ones.

This was also the noisiest part of the whole process:

  • Time limit: the job ran long enough to hit the background time limit, so it had to be resumed from where it stopped.
  • Network drops: 4 notes failed with Connection reset by peer. The script only retried API errors, not network errors.
  • Already deleted blocks: retrying those 4 notes failed with Can't edit block that is archived. The interrupted attempt had already deleted some blocks, and Notion still listed them for a while.

The final version retries network errors, gives up on any request after 60 seconds instead of hanging forever, and skips blocks that are already archived. To make sure the 4 notes were not left half-empty or duplicated, we compared the number of blocks in Notion with the expected number.

Conclusion

  • Look at the data first. Inspecting the backup before planning revealed things I did not know: three note types, images in a separate array, notes without a category.
  • Always do a practice run. One place was enough to change a decision I would otherwise have regretted across 28 places.
  • A state file is cheap and powerful. It made the import safe to resume, and later made it possible to update existing pages.
  • The agent does not replace your eyes. Claude handled the API details, the edge cases and the error handling. But the clickable links, the Others page and dropping the tags all came from me looking at the result and saying "this does not look right".

My travel notes now live in Notion, organized exactly the way I wanted.

Thank you for reading and see you in the next one!

About the author

  • I love learning new stuff, especially when it comes to building software. I'm really interested in software architecture, clean code, testing and best practices.

No comments yet. Be the first to comment.