| name | obsidian-data-handling |
| description | Implement vault data backup, sync, and recovery strategies.
Use when building backup features, implementing data export,
or handling vault synchronization in your plugin.
Trigger with phrases like "obsidian backup", "obsidian sync",
"obsidian data export", "vault backup strategy".
|
| allowed-tools | Read, Write, Edit |
| version | 1.13.0 |
| license | MIT |
| author | Jeremy Longshore <jeremy@intentsolutions.io> |
| tags | ["saas","obsidian","backup"] |
| compatibility | Designed for Claude Code |
Obsidian Data Handling
Overview
Data management patterns for Obsidian plugins: plugin config with loadData/saveData, vault file I/O, frontmatter parsing via metadataCache, handling renames and deletes, cross-device sync considerations, and IndexedDB fallback for large datasets.
Prerequisites
- Working Obsidian plugin (
export default class extends Plugin)
- Understanding of Obsidian's
Vault and MetadataCache APIs
- TypeScript compilation configured
Instructions
Step 1: Plugin Config with loadData / saveData
Obsidian stores plugin data in .obsidian/plugins/<plugin-id>/data.json. Use loadData() and saveData() — never read that file directly.
interface PluginConfig {
version: number;
apiEndpoint: string;
syncInterval: number;
excludedFolders: string[];
}
const DEFAULT_CONFIG: PluginConfig = {
version: 1,
apiEndpoint: 'https://api.example.com',
syncInterval: 300,
excludedFolders: [],
};
export default class DataPlugin extends Plugin {
config: PluginConfig;
async onload() {
await this.loadConfig();
}
async loadConfig() {
const saved = await this.loadData();
this.config = Object.assign({}, DEFAULT_CONFIG, saved);
if (this.config.version < 1) {
.. = [];
.. = ;
.();
}
}
() {
.(.);
}
}
loadData() returns null on first run — Object.assign onto defaults handles this cleanly.
Step 2: Reading and Writing Vault Files
import { TFile, TFolder, normalizePath } from 'obsidian';
async readNote(path: string): Promise<string | null> {
const file = this.app.vault.getAbstractFileByPath(normalizePath(path));
if (file instanceof TFile) {
return await this.app.vault.read(file);
}
return null;
}
async writeNote(path: string, content: string): Promise<TFile> {
const normalized = normalizePath(path);
const existing = this.app.vault.getAbstractFileByPath(normalized);
if (existing instanceof TFile) {
await this...(existing, content);
existing;
}
dir = normalized.(, normalized.());
(dir && !...(dir)) {
...(dir);
}
...(normalized, content);
}
(: , : ): <> {
file = ...((path));
(file ) {
...(file, + text);
}
}
Use vault.cachedRead() instead of vault.read() when you don't need the absolute latest content — it avoids hitting disk on every call.
Step 3: Working with Frontmatter via MetadataCache
Never parse YAML frontmatter manually. Obsidian's metadataCache keeps a parsed cache of every file's frontmatter.
import { TFile, CachedMetadata } from 'obsidian';
getFrontmatter(file: TFile): Record<string, any> | null {
const cache: CachedMetadata | null = this.app.metadataCache.getFileCache(file);
return cache?.frontmatter ?? null;
}
async setStatus(file: TFile, status: string): Promise<void> {
await this.app.fileManager.processFrontMatter(file, (fm) => {
fm.status = status;
fm.updated = new Date().toISOString();
});
}
getFilesWithTag(tag: string): TFile[] {
const : [] = [];
( file ...()) {
cache = ...(file);
tags = cache?.?.( t.) ?? [];
fmTags = cache?.?. ?? [];
(tags.(tag) || fmTags.(tag.(, ))) {
files.(file);
}
}
files;
}
processFrontMatter handles YAML serialization correctly — it preserves comments and formatting, and is the only safe way to update frontmatter programmatically.
Step 4: Handling File Renames and Deletes
Plugins that index file paths must update their state when files move or disappear.
async onload() {
this.registerEvent(
this.app.vault.on('rename', (file, oldPath) => {
if (file instanceof TFile) {
this.onFileRenamed(file, oldPath);
}
})
);
this.registerEvent(
this.app.vault.on('delete', (file) => {
if (file instanceof TFile) {
this.onFileDeleted(file.path);
}
})
);
}
private onFileRenamed(file: TFile, oldPath: string) {
if (this.config.pinnedFiles?.includes(oldPath)) {
const idx = this.config..(oldPath);
..[idx] = file.;
.();
}
}
() {
(..?.(path)) {
.. = ...( p !== path);
.();
}
}
Always use registerEvent — it automatically cleans up the listener when the plugin unloads.
Step 5: Cross-Device Sync Considerations
Obsidian vaults synced via iCloud, Dropbox, or Obsidian Sync introduce eventual consistency issues.
interface SyncSafeConfig {
items: Record<string, { value: string; updatedAt: number }>;
}
mergeConfigs(local: SyncSafeConfig, remote: SyncSafeConfig): SyncSafeConfig {
const merged: SyncSafeConfig = { items: {} };
const allKeys = new Set([
...Object.keys(local.items),
...Object.keys(remote.items),
]);
for (const key of allKeys) {
const l = local.items[key];
const r = remote.items[key];
if (!l) merged.items[key] = r;
else if (!r) merged.items[key] = l;
else merged.items[key] = l.updatedAt >= r. ? l : r;
}
merged;
}
Guidelines for sync-friendly plugins:
- Avoid storing file paths in
data.json — they differ across devices with different vault locations
- Use file content hashes or frontmatter IDs for identity instead of paths
- Keep
data.json small — large files cause sync conflicts and slow sync
Step 6: IndexedDB Fallback for Large Datasets
When plugin data exceeds what's practical for data.json (more than ~1MB), use IndexedDB.
class PluginDatabase {
private db: IDBDatabase | null = null;
private dbName: string;
constructor(pluginId: string) {
this.dbName = `obsidian-${pluginId}`;
}
async open(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains('cache')) {
db.createObjectStore('cache', { keyPath: 'id' });
}
};
request.onsuccess = (event) => {
this. = (event. ).;
();
};
request. = (request.);
});
}
(: , : ): <> {
(!.) ();
( {
tx = .!.(, );
tx.().({ id, data, : .() });
tx. = ();
tx. = (tx.);
});
}
(: ): < | > {
(!.) ();
( {
tx = .!.(, );
request = tx.().(id);
request. = (request.?. ?? );
request. = (request.);
});
}
() {
.?.();
. = ;
}
}
() {
. = (..);
..();
}
() {
.?.();
}
IndexedDB is per-device and does not sync across devices. Use it for caches and derived data that can be rebuilt, not for primary user data.
Output
- Plugin config loading with version migration
- Safe vault file read/write/append operations
- Frontmatter access via metadataCache
- Rename and delete event handlers
- Sync-friendly data structures
- IndexedDB storage for large datasets
Error Handling
| Issue | Cause | Solution |
|---|
loadData() returns null | First run, no data.json yet | Object.assign onto defaults |
| Frontmatter returns undefined | File not yet indexed by cache | Listen for metadataCache.on('resolved') |
| File write fails | Parent folder doesn't exist | Create folder with vault.createFolder() first |
| Settings lost after sync | Concurrent writes from two devices | Use merge-friendly data structures with timestamps |
| data.json too large / slow | Storing too much data | Move large data to IndexedDB |
| stale cache after modify | cachedRead returns old content | Use vault.read() when freshness matters |
Examples
Export All Notes with Tag to JSON
async exportTaggedNotes(tag: string): Promise<string> {
const files = this.getFilesWithTag(tag);
const notes = await Promise.all(
files.map(async (f) => ({
path: f.path,
content: await this.app.vault.read(f),
frontmatter: this.getFrontmatter(f),
}))
);
return JSON.stringify(notes, null, 2);
}
Atomic Config Update
async updateConfig<K extends keyof PluginConfig>(
key: K,
value: PluginConfig[K]
): Promise<void> {
this.config[key] = value;
await this.saveConfig();
}
Resources
Next Steps
For team access control patterns, see obsidian-enterprise-rbac. For performance with large vaults, see obsidian-rate-limits.