Roam Research → Logseq DB (v2) migration
What this is and why it's not obvious
Logseq split into two products: the old file/Markdown version (OG), which has a built-in
"Import Roam JSON" — and the new DB/SQLite version (v2), which is now the default and has no
Roam importer at all (only SQLite / File→DB / EDN imports). There is also no community tool
targeting DB v2. So a naive attempt flails. This skill encodes a path that works.
The method — do not deviate without reason:
Roam JSON → [scripts/roam_to_logseq.py] → Logseq FILE graph (.md + assets/ + logseq/config.edn)
→ [official File→DB importer] → DB graph
We produce a file graph (the universal intermediate every Roam→Logseq tool emits) and feed it
to Logseq's own File→DB importer. We do NOT hand-write the DB (the tempting import-edn /
upsertNodes path) — see the first gotcha for why that silently loses every image.
Prerequisites
- The Roam export must be JSON (not Markdown). Roam Markdown export drops block references;
only JSON carries them. If the user has Markdown, have them re-export as JSON.
- The converter assumes Roam's default English daily-note titles ("January 25th, 2026"). A graph
using a different daily-note display format will silently have its daily notes become ordinary
pages (journal detection fails) — adjust
DATE_RE in the converter and confirm via the
journal-count QC check. Watch for this when migrating your other graphs, which may differ.
- The File→DB importer must be runnable. Two ways (pick per environment — see
references/toolchain-setup.md):
- Headless / fully scripted (best when a Logseq source checkout is available): the nbb
script
deps/graph-parser/script/db_import.cljs. One-time toolchain setup required.
- GUI fallback (works for anyone with Logseq desktop, no source): after producing the file
graph, the user clicks Import → File to DB graph in the app. Same engine, one click.
Workflow
Read references/conversion-rules.md (what the converter does to each Roam construct, and why)
and references/qc-and-troubleshooting.md (verification queries + failure modes) before running —
they carry the load-bearing detail this hub omits.
- Inventory the export first. Skim the Roam JSON for scale and which constructs are present
(block refs
((uid)), {{[[table]]}}, images, {{[[TODO]]}}, code fences, daily notes,
/-in-title pages). This tells you what to spot-check later. The converter handles all of them,
but knowing the counts lets you verify nothing silently vanished.
- Convert:
python scripts/roam_to_logseq.py --input <roam.json> --out <file-graph-dir>.
It downloads+localizes images (this can take a few minutes; re-runnable — it skips already-
fetched files), converts every construct, and writes a Logseq file graph. Read its --help.
- Sanity-sweep the file graph (cheap, catches converter regressions before a slow import):
no leftover
{{[[table]]}} / {{ macros, no raw firebasestorage URLs, block-ref id::
present, file counts ≈ (pages − dropped) + journals. See references/qc-and-troubleshooting.md.
- Import to a STAGING graph name first (never straight to the user's target). This proves
scale + fidelity with zero risk. Headless:
db_import.cljs <file-graph> <staging-name> -c -V.
The -V runs Logseq's own validator; -c continues past a bad page so one glitch doesn't abort
a 15-min import (then check its "ignored files" report).
- QC the staging graph against the source (per
references/qc-and-troubleshooting.md): counts
reconcile, dangling-ref scan = 0, images copied to <graph>/assets/, journals/tasks/assets
present, no created-at:: leaked into block content. Spot-check a table page and an image page.
- Final placement. A DB graph cannot be renamed and the importer creates the graph by
name, so to land it under the user's exact target name you must import directly to that name —
which requires the app to release it. See the quit-Logseq gotcha. Confirm with the user before
this step (it's the one action that touches their target).
Scale it to the task: a 100k+ block graph deserves the full staging→QC→final dance; a tiny graph
can go straight in. But the gotchas below are non-negotiable regardless of size.
Gotchas (read these — they are why this took real effort to get right)
- build-EDN / direct-DB-write CANNOT copy image files.
import-edn and the HTTP API are pure
DataScript transactions that never touch the filesystem, so a hand-built #Asset node renders
as a broken image (bug #31023). Only the File→DB importer physically copies assets/ and
wires up #Asset nodes. This is the reason the whole method routes through a file graph. If
you're ever tempted to "just write the DB directly," you will lose every image. Don't.
- Block references resolve as
[[uuid]], NOT Roam's ((uuid)). In DB v2's file importer,
((uuid)) stays literal text; the target block needs id:: <uuid> and the reference site must
be [[<uuid>]]. (The converter does this.) Source-code reading suggested ((uuid)) works — it
does not in v2.0.1; only empirical testing caught it. Trust the dangling-scan, not the docs.
- Quit Logseq before importing to a graph the app holds open. The desktop app locks the open
graph's SQLite via its db-worker; a second writer corrupts it. To place content under the user's
exact target name: have them fully quit Logseq (Cmd+Q, not just switch graphs), then move
the empty placeholder graph dir aside (don't delete —
mv to .bak), import to that name, and
tell them to reopen. Verify the app is actually gone (pgrep) before writing.
- A stale db-worker serves OLD data and will lie to your QC. The
logseq CLI keeps a per-graph
worker alive; after a re-import into the same name, that worker still holds the previous graph in
memory, so your verification queries read stale numbers. Kill the worker
(pkill -9 -f "logseq_db_<name>") before every QC pass and after every re-import. When a fix
"didn't take," suspect this first.
- A DB graph cannot be renamed — copying/
mv-ing the graph dir yields a graph whose worker
won't start (identity is baked in). So the target name is chosen at import time; there is no
cheap rename. Plan the double-import (staging → final) accordingly.
- The import is non-idempotent. Re-running appends duplicate blocks. Import once into a fresh
graph; on any failure, drop the graph and re-run from scratch. Deterministic UUIDs (the converter
uses uuid-from-roam-uid) make re-runs reproducible.
- Blockquotes, code fences, and multi-line blocks swallow trailing property lines. mldoc treats
the indented
created-at:: after a > quote / ``` / multi-line block as *content*, so
the property leaks into the block text. The converter skips timestamps on such blocks and forces
inline-opened code fences onto their own line — but if you edit the converter, re-check for
created-at:: leaking into :block/title after import.
roam-to-logseq (the pip package cited by blogs) does not exist — it's AI-generated SEO
spam. Real references: Logseq OG's own roam.cljs (conversion semantics), sebastianpech/ roamtologseq and romilly/logseq-migration (asset download) — all target the file version.
Files in this skill
scripts/roam_to_logseq.py — the converter (Roam JSON → Logseq file graph). Self-documenting;
every conversion rule has a comment explaining the empirical reason. Reuse it; don't rewrite it.
references/conversion-rules.md — per-construct conversion table + the reasoning behind each.
references/toolchain-setup.md — set up the headless File→DB importer (deps, babashka,
better-sqlite3, the nbb invocation) + the GUI fallback.
references/qc-and-troubleshooting.md — the verification queries (dangling scan, counts,
construct checks) and how to diagnose the common failures.
1---2name: roam-to-logseq-db3description: Migrate a Roam Research graph into Logseq DB version (v2 / SQLite). Load when the user wants to import, migrate, move, or convert a Roam export (Roam JSON) into Logseq — "import Roam into Logseq", "migrate my Roam graph", "Roam to Logseq", "moving off Roam", "I exported my Roam graph, now what", "导入 Roam 到 Logseq", "从 Roam 迁移", "Roam 转 Logseq". Use even if they don't say "DB version" — the DB/SQLite version is now the default Logseq. NOT for the old file/Markdown Logseq (that version has a built-in Roam importer — use that); this is specifically the DB-v2 path, which has no built-in Roam import.4---56# Roam Research → Logseq DB (v2) migration78## What this is and why it's not obvious910Logseq split into two products: the old **file/Markdown version (OG)**, which has a built-in11"Import Roam JSON" — and the new **DB/SQLite version (v2)**, which is now the default and has **no12Roam importer at all** (only SQLite / File→DB / EDN imports). There is also **no community tool**13targeting DB v2. So a naive attempt flails. This skill encodes a path that works.1415**The method — do not deviate without reason:**1617```18Roam JSON → [scripts/roam_to_logseq.py] → Logseq FILE graph (.md + assets/ + logseq/config.edn)19 → [official File→DB importer] → DB graph20```2122We produce a **file graph** (the universal intermediate every Roam→Logseq tool emits) and feed it23to **Logseq's own File→DB importer**. We do NOT hand-write the DB (the tempting `import-edn` /24`upsertNodes` path) — see the first gotcha for why that silently loses every image.2526## Prerequisites2728- The Roam export must be **JSON** (not Markdown). Roam Markdown export **drops block references**;29 only JSON carries them. If the user has Markdown, have them re-export as JSON.30- The converter assumes Roam's **default English daily-note titles** ("January 25th, 2026"). A graph31 using a different daily-note display format will silently have its daily notes become ordinary32 pages (journal detection fails) — adjust `DATE_RE` in the converter and confirm via the33 journal-count QC check. Watch for this when migrating your *other* graphs, which may differ.34- The **File→DB importer** must be runnable. Two ways (pick per environment — see35 `references/toolchain-setup.md`):36 - **Headless / fully scripted** (best when a Logseq *source checkout* is available): the nbb37 script `deps/graph-parser/script/db_import.cljs`. One-time toolchain setup required.38 - **GUI fallback** (works for anyone with Logseq desktop, no source): after producing the file39 graph, the user clicks **Import → File to DB graph** in the app. Same engine, one click.4041## Workflow4243Read `references/conversion-rules.md` (what the converter does to each Roam construct, and why)44and `references/qc-and-troubleshooting.md` (verification queries + failure modes) before running —45they carry the load-bearing detail this hub omits.46471. **Inventory the export first.** Skim the Roam JSON for scale and which constructs are present48 (block refs `((uid))`, `{{[[table]]}}`, images, `{{[[TODO]]}}`, code fences, daily notes,49 `/`-in-title pages). This tells you what to spot-check later. The converter handles all of them,50 but knowing the counts lets you verify nothing silently vanished.512. **Convert:** `python scripts/roam_to_logseq.py --input <roam.json> --out <file-graph-dir>`.52 It downloads+localizes images (this can take a few minutes; re-runnable — it skips already-53 fetched files), converts every construct, and writes a Logseq file graph. Read its `--help`.543. **Sanity-sweep the file graph** (cheap, catches converter regressions before a slow import):55 no leftover `{{[[table]]}}` / `{{` macros, no raw `firebasestorage` URLs, block-ref `id::`56 present, file counts ≈ (pages − dropped) + journals. See `references/qc-and-troubleshooting.md`.574. **Import to a STAGING graph name first** (never straight to the user's target). This proves58 scale + fidelity with zero risk. Headless: `db_import.cljs <file-graph> <staging-name> -c -V`.59 The `-V` runs Logseq's own validator; `-c` continues past a bad page so one glitch doesn't abort60 a 15-min import (then check its "ignored files" report).615. **QC the staging graph** against the source (per `references/qc-and-troubleshooting.md`): counts62 reconcile, **dangling-ref scan = 0**, images copied to `<graph>/assets/`, journals/tasks/assets63 present, no `created-at::` leaked into block content. Spot-check a table page and an image page.646. **Final placement.** A DB graph **cannot be renamed** and the importer creates the graph *by65 name*, so to land it under the user's exact target name you must import directly to that name —66 which requires the app to release it. See the quit-Logseq gotcha. Confirm with the user before67 this step (it's the one action that touches their target).6869Scale it to the task: a 100k+ block graph deserves the full staging→QC→final dance; a tiny graph70can go straight in. But the gotchas below are non-negotiable regardless of size.7172## Gotchas (read these — they are why this took real effort to get right)7374- **build-EDN / direct-DB-write CANNOT copy image files.** `import-edn` and the HTTP API are pure75 DataScript transactions that never touch the filesystem, so a hand-built `#Asset` node renders76 as a broken image (bug #31023). Only the **File→DB importer physically copies** `assets/` and77 wires up `#Asset` nodes. This is *the* reason the whole method routes through a file graph. If78 you're ever tempted to "just write the DB directly," you will lose every image. Don't.79- **Block references resolve as `[[uuid]]`, NOT Roam's `((uuid))`.** In DB v2's file importer,80 `((uuid))` stays literal text; the target block needs `id:: <uuid>` and the reference site must81 be `[[<uuid>]]`. (The converter does this.) Source-code reading suggested `((uuid))` works — it82 does not in v2.0.1; only empirical testing caught it. Trust the dangling-scan, not the docs.83- **Quit Logseq before importing to a graph the app holds open.** The desktop app locks the open84 graph's SQLite via its db-worker; a second writer corrupts it. To place content under the user's85 *exact* target name: have them fully **quit Logseq (Cmd+Q, not just switch graphs)**, then move86 the empty placeholder graph dir aside (don't delete — `mv` to `.bak`), import to that name, and87 tell them to reopen. Verify the app is actually gone (`pgrep`) before writing.88- **A stale db-worker serves OLD data and will lie to your QC.** The `logseq` CLI keeps a per-graph89 worker alive; after a re-import into the same name, that worker still holds the previous graph in90 memory, so your verification queries read stale numbers. **Kill the worker**91 (`pkill -9 -f "logseq_db_<name>"`) before every QC pass and after every re-import. When a fix92 "didn't take," suspect this first.93- **A DB graph cannot be renamed** — copying/`mv`-ing the graph dir yields a graph whose worker94 won't start (identity is baked in). So the target name is chosen at import time; there is no95 cheap rename. Plan the double-import (staging → final) accordingly.96- **The import is non-idempotent.** Re-running appends duplicate blocks. Import once into a fresh97 graph; on any failure, drop the graph and re-run from scratch. Deterministic UUIDs (the converter98 uses uuid-from-roam-uid) make re-runs reproducible.99- **Blockquotes, code fences, and multi-line blocks swallow trailing property lines.** mldoc treats100 the indented `created-at::` after a `> quote` / ```` ``` ```` / multi-line block as *content*, so101 the property leaks into the block text. The converter skips timestamps on such blocks and forces102 inline-opened code fences onto their own line — but if you edit the converter, re-check for103 `created-at::` leaking into `:block/title` after import.104- **`roam-to-logseq` (the pip package cited by blogs) does not exist** — it's AI-generated SEO105 spam. Real references: Logseq OG's own `roam.cljs` (conversion semantics), `sebastianpech/106 roamtologseq` and `romilly/logseq-migration` (asset download) — all target the *file* version.107108## Files in this skill109110- `scripts/roam_to_logseq.py` — the converter (Roam JSON → Logseq file graph). Self-documenting;111 every conversion rule has a comment explaining the empirical reason. Reuse it; don't rewrite it.112- `references/conversion-rules.md` — per-construct conversion table + the reasoning behind each.113- `references/toolchain-setup.md` — set up the headless File→DB importer (deps, babashka,114 better-sqlite3, the nbb invocation) + the GUI fallback.115- `references/qc-and-troubleshooting.md` — the verification queries (dangling scan, counts,116 construct checks) and how to diagnose the common failures.