↓Skip to main content
  1. Emacs/

Migrating Obsidian to org-mode

·

I recently had a couple of weeks of vacation and decided it was a good time to look into how to migrating my approximately 1,000 Obsidian notes to Emacs Org-mode. I had considered doing this for a long time, but I had always hesitated because I was afraid the conversion would be messy and time-consuming and would prevent me from reproducing my established workflow. I was wrong. At least partly wrong. The migration took some time (a couple of days of fiddling with Python and Pandoc export scripts), but I was eventually able to recreate my entire Obsidian workflow.

This note is for people who, like me, have been pondering whether to migrate from Obsidian to Emacs Org-mode for quite some time. Perhaps there are new Emacs users who are feeling hesitant. Hopefully, this note will help you decide, and if you decide to migrate, it will share a few tips about how to organize your vault of notes.

Speaking of vaults. There are many different names for the same concept: brain dump, vault (especially among Obsidian users), second brain, personal knowledge base (PKB) system, Zettelkasten system, or simply a note collection. For the sake of simplicity, I will refer to it as a “vault of notes” because that term feels more natural for people coming from Obsidian.

How my Obsidian setup looked like #

First, let me clarify what was in my Obsidian setup and what was not.

What it contained:

  • Multiple strictly disjoint vaults (work and private). Other people may also want a shared vault for family, a club, or a common interest, such as role-playing games. The org-mode workflow presented below covers strictly disjoint vaults, too.
  • Notes used a YAML preamble, which included:
    • UUID: a unique identifier is not typical in Obsidian vaults, but I have always found it useful for linking from outside Obsidian to a particular Obsidian note; there is a little Obsidian extension linked here that supported it. This ensured for me that my links remained stable, even when the Markdown files were subsequently renamed.
    • dates: used to store creation and modification dates without relying on btime and mtime.
    • tags: useful for restricting the search criteria when searching for a note.
  • Internal links between notes were Obsidian-flavored wikilinks.

What it did not contain:

  • I did not use proprietary Obsidian Canvas, so I did not look into how to export those notes.
  • No plugin was needed to view my notes, except for a handful of summary notes generated by Dataview and PDF annotations made with PDF++. So, the majority of notes were plain Markdown, which greatly helped when exporting them to the Org format.

Why I decided to migrate to org-mode #

Org-mode aficionados can skip this section. They already know why Org mode in Emacs is superior πŸ˜„. But for everyone else, let me spell out the reasons that convinced me to move my notes from Obsidian to Emacs.

  • I would rather edit notes in Emacs. Emacs is my daily driver for anything I can do with a text editor. There was always friction when I had to copy and paste content from Emacs into Obsidian. Although I could use Emacs to directly edit Obsidian notes, I had to install an “advice” in Emacs, markdown-ts-mode, to handle the Obsidian Markdown dialect, especially the wikilinks. Somehow, it always felt awkward to have to patch Emacs to edit notes written for an editor I hardly ever use.
  • Obsidian is closed source, so extending it is reverse engineering. I wrote two plugins in the past, one to import attachments (link) and one to annotate why I had installed plugins X, Y and Z (link). Before LLMs became so advanced, maintaining them was a serious undertaking, and they would break with every update to Obsidian’s internal, undocumented functions. I should admit that, with recent LLMs, repairs went from taking “a couple of weekends” to only “60 minutes.” However, I still don’t want my notes to depend on my ability to reverse engineer someone’s private API.
  • It is getting more commercial. I have nothing against people earning a living from software. However, I do mind installing a plugin advertised as free and discovering that free meant the first 15 days only.
  • The hidden lock-in effect. Yes, Markdown notes can be read and edited by any editor. However, the more I used Obsidian, the more locked in I felt. As I mentioned, Obsidian’s own Markdown dialect makes it difficult to edit notes in other editors. Additionally, built-in features like Canvas and third-party plugins like PDF++ and Dataview make some of your notes completely unreadable without them. Don’t get me wrong; these are genuinely good plugins. However, as one starts relying on them, it becomes more difficult to migrate to anything else. I have experienced too many times before that notes stored in other PKB apps later became of little use (or, when exported, became poorly formatted and difficult to reuse). My notes should stay readable for as long as possible. I trust Org mode and Emacs to be around for many years to come. Instead, I am not equally confident that PDF++ will open my paper annotations in five years.
  • It uses a proprietary Markdown dialect. I already mentioned Wikilinks, which only work inside Obsidian and break when another editor opens the file. There’s more. Extending the Markdown format is not simple. You can’t just write an “advice” like in Emacs to add functionalities. For example, I have always wanted to use the square-bracket LaTeX delimiters \[ and \] for display equations and \( and \) for inline equations, instead of the traditional TeX alternative based on dollars $. As of today, this is not possible.

Conversely, Org mode does not have these limitations. The following comes to mind as something missing in Markdown:

  • There is not a plurality of dialects, just a single, canonical Org syntax that will likely be supported for many years to come. Luckily (or sadly, depending on your point of view), Emacs Org mode will likely outlive us all (except for Bryan Johnson πŸ˜…).
  • Key-value data, as provided by description list items.
  • Much better support for internal links, including links to numbered figures.
  • A standardized concept for handling attachments, with its own attachment: link type.
  • Built-in support for “TODO” and personal agendas.
  • There is a recent trend to develop more and more applications for editing Org notes outside the Emacs ecosystem. For example, take a look at the Orgzly Revived, beorg, openorg and org-mobile projects. MobileOrg seems less maintained and I did not check it myself.

Others have written about the advantages of the Org format over Markdown; take a look at this write-up.

The backbone: vulpea, not org-roam #

I use vulpea to index the vault. I looked at org-roam first and decided against it, essentially for the reason its own author-adjacent post makes: org-roam is designed as an application, vulpea as a library. I do not need another application. Emacs already has buffers, windows, completion, and I like how it handles them. I needed a small, agile layer that knows which notes exist and what links to what.

Boris Buliga’s post is the best statement of that distinction, and it is why I went this way: https://www.d12frosted.io/posts/2025-11-28-vulpea-v2-breaking-up-with-org-roam

Thank you, @d12frosted, for vulpea β€” v2 is precisely the right size of tool.

Backlinks, incidentally, are a non-issue: vulpea keeps every link with its type and position, so “what links here” is a query, not a feature request.

Multiple vaults #

Two things I had to add, both small:

vulpea-vault-switch β€” opening a different vault without restarting Emacs. A vault turns out not to be one setting but six, and three of them belong to other packages: org-attach reads org-attach-id-dir, vulpea reads vulpea-db-sync-directories and opens its database from vulpea-db-location. Reassigning the variables mid-session is not enough β€” the old database stays open at the old path and the file watcher keeps watching the tree you left. So the command does the whole sequence: stop the watcher, close the database, re-point everything from a single vault root, start the watcher again. A vault opened for the first time indexes itself. Everything derives from one root, because a vault re-pointed by hand is a vault half re-pointed.

vulpea-vault-orphans β€” a health check for the notes. It reports the two complementary failures of an ID-keyed store: notes pointing at something that is not there, and files in the store nothing points at. There is an Obsidian plugin that does roughly this, and I wanted to keep the equivalent. It is not the same thing as the built-in vulpea-doctor, whose scope is the vault and the index rather than the health of the notes themselves.

One nuance that matters in a converted vault: link types Org cannot resolve are counted, not listed as dangling. My notes carry several hundred x-bdsk:, pdffile: and message: links, which org-element files as fuzzy; calling all of them broken would bury the handful of genuinely dangling links I actually want to see.

.dir-locals.el is the sleeper feature #

This is the part I most want other Emacs users to take away, because I do not think .dir-locals.el gets the credit it deserves. Each vault declares, in its own root, what it is and how it works:

  • its tag vocabulary (org-tag-alist, and the group structure between tags),
  • where a new note goes and what it starts as β€” this replaces the Templater plugin,
  • which folders have a role: the daily notes folder, templates, inbox. Code asks for the role and gets a path, so 01 Daily notes/ stays the vault’s business and a second vault can spell it differently or not have one at all.

That last point is worth stressing: not just the template but the destination is vault policy. Between them, that is Periodic Notes and Templater in one mechanism, built out of machinery Emacs already ships.

Four things I learned the hard way, all of which apply to any use of dir-locals:

  1. Variables need a safe-local-variable predicate, or Emacs asks you on every single note you open. And write the predicate carefully: a predicate that errors makes Emacs discard the whole .dir-locals.el, not just that one variable.
  2. Dir-locals are applied after the major mode has run. By then org-mode has already derived org-current-tag-alist β€” the buffer-local value everything downstream actually reads β€” from the global settings. Setting org-tag-alist from dir-locals does nothing whatsoever until that derivation runs again. One hook fixes it, but the failure is silent.
  3. Emacs never merges dir-locals up a directory tree. The deepest file wins wholesale, and there is no option to change that. A .dir-locals.el in a subfolder would silently shadow the vault root’s entire declaration β€” so it is one file per vault root, using ("subdir/" . ...) string keys for anything folder-specific.
  4. Version the schema. What a vault may declare lives in one place, and the vault announces itself with a schema version. Without that, changing the set of declarations leaves older vaults quietly misbehaving instead of reporting.

I am considering opening a discussion on vulpea’s GitHub proposing a minimal common scheme along these lines β€” enough that someone can adopt the pattern quickly, without over-specifying it. vulpea has to stay a flexible library; that is the whole reason I chose it.

Frontmatter β†’ property drawer #

That second point is the one that made the migration feasible at all. Wikilinks and YAML frontmatter are mechanical to convert; a note whose content only exists inside a plugin’s renderer is not.

Obsidian’s YAML frontmatter maps cleanly onto Org: a file-level property drawer plus file-level keywords (#+title:, #+filetags:). The uuid in my frontmatter became the :ID: one-to-one, which is what made the whole link conversion possible.

One gotcha worth publishing: vulpea indexes properties, not arbitrary keywords. I first wrote the timestamps as #+created: / #+modified: keywords, which reach the database as nothing at all β€” they are text only grep can find. As :CREATED: and :MODIFIED: properties they are facts you can query (vulpea-note-created-at, vulpea-db-query-by-created-date). The drawer belongs to the file-level node, which is exactly the node those stamps describe.

:MODIFIED: is refreshed on save, which is what the frontmatter-modified-date plugin did in Obsidian. It only touches buffers that already have the property, so it never imposes the convention on an Org file that does not use it.

Attachments: org-attach did what my own plugin did #

This is the one that closed a loop for me. I wrote an Obsidian plugin to keep attachments in a per-note store. In Org, that is two settings:

  • org-attach-preferred-new-method = id, so a note’s attachments follow it through renames and moves β€” the store is keyed by something the note carries, not by where it sits. I am happy with directories named after the UUID precisely because there is nothing left to keep in sync.
  • org-attach-use-inheritance = t, because the key is at file level. The note carries its :ID: in the file-level drawer while attachment: links sit under whatever heading the text put them. With the default selective, org-attach-dir looks only at the entry at point, finds no ID, and you get “No such file” against the note’s own directory.

No plugin, no reverse engineering, no maintenance.

If there is one reusable lesson in this whole migration, it is this: the converter does not have to understand every link β€” Org can be taught the syntax instead.

My vault carries three link types that are not Org’s:

  • message: β€” the scheme Mail.app registers, from messages dragged into a note (23 of them).
  • x-bdsk: β€” BibDesk’s publication URLs (several hundred), which is how my citations work.
  • pdffile: β€” not a registered scheme at all. It came from an AppleScript that copied a reference to the page open in Acrobat by gluing the literal text pdf in front of a file:// URL, with an optional ?page=N.

org-element files all of them as fuzzy β€” internal search targets β€” and following one fails. About 30 lines of org-link-set-parameters per type fixed that: the first two hand the URL to the OS, which routes it to Mail or BibDesk; pdffile: I open inside Emacs, stripping the pdf prefix and handing path and page to my own preview function, so it honours the same viewer setting (Skim, pdf-tools, or the OS handler) as my LaTeX and Typst previews.

Two details I would not have guessed:

  • Take the path from Org’s own parse, not a regexp over the buffer text. These filenames contain spaces, parentheses and commas β€” … (Oxford University Press, 2005).pdf β€” and no naive pattern survives them.
  • My pdffile: links still carry the query parameters PDF++ put there: page, rect, color, selection, annotation. Nothing consumes them today, and I deliberately did not strip them. A richer handler β€” resolve the citekey, open the Nth attached file in Skim at that page and highlight β€” can be written later, because the information was never thrown away.

vulpea’s index and org-id are two different indexes #

Worth knowing before you import a thousand notes, because the symptom is one-sided and puzzling: vulpea-find finds a note, while [[id:…]] to that same note fails.

vulpea and org-id read the same :ID: property but keep separate indexes β€” vulpea its SQLite database, org-id its org-id-locations. vulpea does not hook org-id: its own commands resolve through the database, while following a plain id link goes through org-id-locations.

vulpea recently gained a hook that fires when something in the vault changes (vulpea-db-updated-functions), so keeping the two in step is now a few lines: a note arriving from outside Emacs becomes followable as it is indexed, and a deleted note stops being. Nothing is forced, nothing is duplicated. I keep a small repair command for the one case the hook cannot cover β€” org-id-locations lost while vulpea’s database is current, so every file is unchanged and the hook stays quiet.

The conversion itself #

Pandoc for the bulk, then Python scripts for the sharp edges Pandoc leaves behind. Roughly:

  • Wikilinks β†’ id: links, resolved through the uuid frontmatter. This is the step that justifies everything else, and it is only possible because every note already had a stable identifier. If your vault does not, generate them in Obsidian first, while it is still the tool that understands your links. (I also had UUID duplicates to clean up β€” check for them.)
  • Embeds (![[…]]), callouts, footnotes, math delimiters: each needs its own pass, none is hard.
  • Tags. YAML tag lists become #+filetags:. The interesting case is hierarchy: Obsidian tags were #Log/Daily, #Teaching/E4, and org tags cannot contain a slash. The converter keeps the last segment and expresses each parent as a tag group, declared per vault in .dir-locals.el. Searching Log still matches Daily and Meeting, which is what the hierarchy was for.
  • Dataview notes. These are the notes that do not survive mechanically, because they were never content β€” they were a query result. ⟨one sentence on what you did with them⟩
  • Titles. My notes carried their date twice, in the filename and again at the head of the title. A script stripped the copy in the title β€” 905 notes β€” because the title is exactly what the note-selection UI shows.

Then verify, do not trust. Two things I found only by looking:

  • 289 notes had a :MODIFIED: more than 90 days after the note’s own date.
  • The import overwrote mtime on 59 files with the import date β€” so file mtime was not a usable source of truth, and the filename date was (it matched in every one of the 905 stripped notes).

Practical advice: work on a copy, keep the Obsidian vault around read-only for a while, and convert one vault first. The second one is an afternoon once the scripts exist.

I am happy to clean up and share the scripts if anyone wants them β€” they are specific to my conventions, but the shape transfers.

Search: ripgrep, and then something better #

For lexical search inside the vault, consult-ripgrep and friends are already better than what I had.

But stored notes are only as valuable as your ability to get them back out, so I also wrote org-semantic, a search package for org-mode: BERT-embedding semantic search, BM25 lexical search, and on top of that a language-identification step, because my notes are a mix of English, German and Italian and a single-language pipeline handles that badly. It indexes what a note says, where vulpea indexes what a note is β€” two indexes over one tree, sharing nothing but the vault root.

That deserves its own post and will get one; I mention it here because it is part of why the migration felt like an upgrade rather than a lateral move.

The rest of the workflow #

  • Agenda. I never used TODOs in Obsidian β€” it never felt like the right tool for my agenda, so I kept that elsewhere. In Org it is the opposite: TODOs live next to the notes that explain them, and org-agenda feels like the natural way to look at them. This was an unplanned gain.
  • Daily notes. A note created with nothing else to say about where it belongs goes into the daily tree, under its own year. Otherwise it follows the directory you are working in, which in practice means the topic folder of the note you were reading, or whatever dired is listing.
  • Backup. Duplicacy takes hourly snapshots of everything β€” it is powerful, and its core is FOSS.
  • Version history. A backup answers “get it back”; it does not answer “what did I change”. So the notes are also a git repository, and every save is recorded in it without my writing a commit: Magit’s work-in-progress refs put each save on refs/wip/wtree/refs/heads/<branch> β€” an ordinary git ref that simply is not a branch, made with git commit-tree, so git log and Magit show nothing unusual. Every six hours a script folds the window into one commit on the branch and drops the WIP ref. The result is about four readable commits a day, with the last few hours still available save by save. Older detail is discarded on purpose β€” that is the price of a log I can actually skim. This is what replaced Obsidian Sync’s version history for me, and it is diffable, which Sync’s is not.
  • Small conveniences are cheap. Because everything is elisp, a small want takes a small amount of code. Example: my note selection shows two dates β€” the day the note is about (from the filename) and the day it was last edited (from :MODIFIED:), sorting by the second, so a note I touched two weeks ago is near the top without my dating it by hand. That is a handful of lines, and in Obsidian it would have been a plugin.

What I gave up #

Stated plainly, and I am fine with all of it:

  • Mobile. I am not a phone-notes person, so this cost me nothing. For anyone who is, org-note looks genuinely good.
  • WYSIWYG polish. Obsidian’s editor is prettier out of the box.
  • Canvas / Excalidraw. People have written similar packages for Emacs; I have not tried them and do not need them. And if I ever do lock a diagram into a tool, I would rather it be a FOSS Emacs package than Obsidian Canvas.
  • PDF++ live annotation. The one I actually miss. My existing annotations survived as links with their coordinates intact (see above), so nothing was lost β€” but the round trip is not as smooth as PDF++ was.

I am not looking for replacements for these; I have decided I can live without them.

If you are on the fence #

The thing that surprised me is how little of this was invention. The vault-switching and the health check are mine; almost everything else is org-attach, org-id, dir-locals, org-agenda, org-link-set-parameters and Magit doing what they already do, pointed at a converted vault. The intimidating part is not Emacs β€” it is your vault’s own idiosyncrasies, and you are the only person who knows those anyway.

If you have made the same move, I would be glad to hear what you did differently β€” especially around per-vault configuration and attachment layout. And if you are still hesitating: with a plain-Markdown vault and stable identifiers in your frontmatter, it is far more tractable than it looks.