What implementation of time-based workflow is to be followed – system 'mtime' or 'date modified' frontmatter values?

In an ideal world, mtime and date modified dates are synchronized. But it takes a fair amount of effort on the part of the user to account for any edge cases and keep up with code changes, etc.

So, hereby I’ll answer my own question and provide some kind of solution.
I am aware the solution will not reach or address people who are afraid to touch anything looking like code so this is more of an awareness-raiser than anything else.

:shield: Block Auto-Save on Markdown Files

We are going to use a hack to block Obsidian from writing into files when we are forced in Reading Mode in files we set ourselves by frontmatter values. – Yes, even for this you will need some frontmatter or in Obsidian-speak, Properties.

But before we do anything, we need to talk about the problem at hand a bit more.

:stop_sign: False mtime Updates in Obsidian: Why Simply Opening a File Shouldn’t Touch the File

Many Obsidian users working with timestamp-based queries, semi-automated publishing and related everyday workflows on a private or commercial level will invariably run into a subtle but persistent issue:

Obsidian’s internal auto-save mechanisms may modify file system modification time (mtime) of files—even when you didn’t (intend to) change anything.

This isn’t a quirk — it’s a fundamental mismatch between what many users think (well, most people are not even aware of this problem!) mtime means or what it actually records.

Modification time in this case is not about meaningful, user-driven edits. The updates happen any time the file is written to disk — even if the content is unchanged, or the change is triggered by an app just touching metadata, updating caches, or re-saving during idle operations and who knows what else.

This behavior breaks the assumption that file.mtime reliably signals real edits, which is especially dangerous in workflows that rely on freshness, like Dataview queries, syncing tools, or backup filters. It’s not just a publishing concern — it affects every Obsidian user who assumes their recent files are files they recently worked on, rather than files the app silently touched.

So it does affect far more than just publishing query based workflows I was originally trying to protect against in a previous write-up, now deferred from there to here. I felt the need to make this new Help post instead.

The problem has been brought up multiple times on the forum. Just two links here (I cannot look for more now):
https://forum.obsidian.md/t/is-there-a-way-to-disable-auto-save-in-obsidian/41939
https://forum.obsidian.md/t/disable-auto-save-or-change-frequency/14230


:red_exclamation_mark: The Broader Problem

In most file-based systems (Obsidian included), mtime is widely used as a signal for:

  • Recently edited files (in Dataview queries like sort by file.mtime desc)
    • I don’t have Bases yet so I am not conversant with how you can filter by file modification dates.
  • Filtering “new notes” in Daily/Weekly dashboards
  • Triggering publishing pipelines (e.g., “only publish changed files” or “only show files I need to publish again”)
  • Driving sync tools (e.g., Syncthing, Dropbox conflict resolution)

But here’s the catch again: opening a file in Obsidian can change its mtime even if you don’t type in a single character.
Now, scrolling in a file alone may not do anything…actually, I did not intend to cover all bases on what Obsidian does (how Live Preview rendering or cursor placements in Live Preview, etc. can affect anything… I do not even know what I should think of…) or why, when I wanted a solution.
I also cannot do anything against plugins modifying frontmatter of files, etc. silently in the background.

So what we need to do is protect against these false positives—files that look newly edited but were only viewed or dirtied against our will. That’s a serious problem for workflows relying on mtime.


:brain: Why mtime Matters More Than You (didn’t) Think

Let’s say you’re running a simple Dataview query to list your 10 most recently updated notes:

table file.mtime
from ""
sort file.mtime desc
limit 10

You open a random file to check something… and boom—it’s now at the top of that query. You didn’t change anything, but Obsidian (or it can be any plugin running in Obsidian) wrote to the file anyway.

  • We are mainly concerned with core Obsidian quirks of course, but it may not be only Obsidian’s internal saving mechanism at fault here. It could be a plugin.
    • For instance I use Note Toolbar with toolbars based on status property values: when I enter a file and don’t even scroll in it, that file is marked by git as touched (with no content changes). Touched means the file was written to and mtime timestamps were changed, consequently, in any queries I can expect this file to be come as a false positive.

Multiply that by dozens of notes opened daily innocently as you go along your daily routine, and your freshness metrics are ruined.

This affects any queries where the user wants to know when a note was truly manually edited last: any recently modified queries, any project related stuff, any published material that needs to be updated on your blogs. The problem is quite huge and not really getting enough attention.

Actually, the issue is significant enough to make me wonder: Why am I being dragged into this, having to resolve it—with AI help, no less? I’m just a researcher trying to maintain my work within my established routine. I don’t want false positives. I don’t want to re-publish files that contain no meaningful manual updates. It’s a waste of resources—both on my end and on the site builder’s. Everyone’s time is valuable not to mention electricity costs, wear and tear on machinery, etc.


What fixes or workarounds are available?

Some people (probably a lot of people since the introduction of Properties by Obsidian) rely on dedicated frontmatter date modified based workflows. Just one link:
https://forum.obsidian.md/t/automatically-update-last-modified-date-in-note/51776

So your above Dataview query changes to (assuming your field is date_modified):

table date_modified
from ""
where date_modified
sort date_modified desc
limit 10

This method has one main drawback, though: when you effect vault-wide changes, whether by internal Obsidian initiated renames of files, headings or blockIds, or by internal search and replace plugins or external text editors to replace typos, etc., then these changed files will not come up as results on your date modified stamp comparing queries.
You’d need to script your own search and replace operations (in Python or in an Obsidian plugin or pseudo-plugin of your own) to update your date modified values.
Actually, you’d also need to hack Obsidian internal link update mechanisms to intercept those Wikilink (link and embed cache) updates and update your date modified values internally, which is no mean task, again, waiting for can-do developers to be solved.
I even offered a rare Plugins ideas post of mine about this before:


My Advice

My advice then is to keep both options alive: keep your date modified updated with a plugin (I use @AlanG’s Frontmatter Modified Updater plugin) and try to switch to querying mtime stamps in your queries to protect against loss of updates resulting from renames and mass replacements.

  • I of course only allow myself to ping 3rd party plugin developers to draw attention to important issues and to look over code. Hehe.

:locked_with_key: My Workaround: A Patch to Stop Obsidian From Writing Unless Content Actually Changes

To address the problem, I have had ClaudeAI 4.0 and ChatGPT 4.1 collaborate (without them knowing about it) to build a patch for Obsidian that protects files from unintended writes. It ensures mtime only reflects real user-driven edits.

As I said, originally, I wrote this patch to protect published notes (e.g., avoid having to re-upload unchanged files). But I’ve since realized this is a universal fix (no, not fix: hack or workaround and no, it is not universal: I am effecting universal fixes elsewhere in my life) for a core weakness in how many file-based editors behave.


:light_bulb: Takeaway

If your workflow relies on mtime to track real edits—like for publishing, syncing, or freshness scoring—keep this in mind:

  • Obsidian updates mtime on every save, even if nothing in the file actually changed.
  • This is expected behavior in many editors, but it means mtime alone doesn’t reliably reflect content changes.

Whether you’re publishing, querying, or syncing—metadata — whether seen or trackable by you or not — integrity matters. And in a file-based ecosystem, controlling when mtime changes is not optional. It’s foundational.

What can the future be based/“Bases-ed” on?

The script/hack below will work until Obsidian changes anything on API level.

With the introduction of Bases, I expect that Feature Requests will sooner or later reach the Obsidian dev team on how to properly handle time-based queries with or without dedicated frontmatter properties.
Will they allow auto-save to be switched off by you? I do not know. They might. Or they will urge you to use date modified properties. Then you’ll need third party developer help.


Script

import { MarkdownView, Notice, TFile, App } from 'obsidian';

// ================================
// CONFIGURATION
// ================================
const CONFIG = {
    // Multiple frontmatter checks - file is protected if ANY of these match
    frontmatterChecks: [
        { key: 'status', value: 'published' },
        // { key: 'tags', value: 'published' },
        // { key: 'publish', value: true },
        // Regex examples:
        // { key: 'title', value: '/.*/', isRegex: true }, // Match all files if you have title property field in frontmatter
        // { key: 'status', value: '/^(published|uploaded)$/', isRegex: true }, // Match published OR uploaded
        // { key: 'tags', value: '/^draft/', isRegex: true }, // Match tags starting with "draft"
    ],
    
    // Auto-save protection timeout (in milliseconds)
    // How long to keep protection active before auto-cleanup
    protectionTimeout: 180000, // 3 minutes
    
    // Detection delay (in milliseconds)
    // Small delay to ensure metadata is loaded before checking
    detectionDelay: 50
};
// ================================

export async function invoke(app: App): Promise<void> {
    const eventRef = app.workspace.on('file-open', async (file: TFile) => {
        if (!file || file.extension !== 'md') return;

        setTimeout(async () => {
            const fileCache = app.metadataCache.getFileCache(file);
            const frontmatter = fileCache?.frontmatter;

            if (isPublishedFile(frontmatter)) {
                const activeView = app.workspace.getActiveViewOfType(MarkdownView);

                if (activeView && activeView.file?.path === file.path) {
                    // Force preview mode
                    if (activeView.getMode() === 'source') {
                        await activeView.setState({
                            ...activeView.getState(),
                            mode: 'preview'
                        });
                    }

                    // Comprehensive auto-save blocking
                    blockAllAutoSaveForFile(app, file, activeView);
                    
                    new Notice('🔒 File protected from auto-save', 3000);
                }
            }
        }, CONFIG.detectionDelay);
    });

    // Store event reference for cleanup
    if (!(app as any)._publishedFileHooks) {
        (app as any)._publishedFileHooks = [];
    }
    (app as any)._publishedFileHooks.push(eventRef);

    new Notice('📘 Published file auto-save protection enabled');
}

function isPublishedFile(frontmatter: any): boolean {
    if (!frontmatter) return false;
    
    return CONFIG.frontmatterChecks.some(check => {
        const frontmatterValue = frontmatter[check.key];
        
        if (frontmatterValue === undefined || frontmatterValue === null) {
            return false;
        }
        
        // Handle regex patterns
        if (check.isRegex) {
            try {
                // Extract regex pattern from string (remove leading/trailing slashes if present)
                let pattern = check.value;
                let flags = '';
                
                if (typeof pattern === 'string' && pattern.startsWith('/')) {
                    const lastSlash = pattern.lastIndexOf('/');
                    if (lastSlash > 0) {
                        flags = pattern.substring(lastSlash + 1);
                        pattern = pattern.substring(1, lastSlash);
                    }
                }
                
                const regex = new RegExp(pattern, flags);
                
                // Handle array values (e.g., tags)
                if (Array.isArray(frontmatterValue)) {
                    return frontmatterValue.some(item => 
                        regex.test(String(item))
                    );
                }
                
                // Handle direct value comparison with regex
                return regex.test(String(frontmatterValue));
                
            } catch (error) {
                console.error(`Invalid regex pattern: ${check.value}`, error);
                return false;
            }
        }
        
        // Handle non-regex patterns (original logic)
        // Handle array values (e.g., tags)
        if (Array.isArray(frontmatterValue)) {
            // Check if the target value is in the array
            return frontmatterValue.includes(check.value);
        }
        
        // Handle direct value comparison
        return frontmatterValue === check.value;
    });
}

function blockAllAutoSaveForFile(app: App, file: TFile, view: MarkdownView) {
    const fileKey = `autosave_blocked_${file.path}`;
    
    // Prevent duplicate blocking
    if ((app as any)[fileKey]) return;
    (app as any)[fileKey] = true;

    // Store original methods if not already stored
    const originals = (app as any)._originalMethods || {};
    
    if (!originals.vaultModify) {
        originals.vaultModify = app.vault.modify.bind(app.vault);
        originals.adapterWrite = app.vault.adapter.write.bind(app.vault.adapter);
        
        // Hook into the view's save method if it exists
        if (view && typeof view.save === 'function') {
            originals.viewSave = view.save.bind(view);
        }
        
        (app as any)._originalMethods = originals;
    }

    // Block vault.modify
    app.vault.modify = async (targetFile: TFile, data: string) => {
        if (targetFile.path === file.path) {
            console.log(`🚫 Blocked vault.modify for: ${file.name}`);
            return Promise.resolve();
        }
        return originals.vaultModify(targetFile, data);
    };

    // Block adapter.write (lower level)
    app.vault.adapter.write = async (path: string, data: string) => {
        if (path === file.path || path.endsWith(file.path)) {
            console.log(`🚫 Blocked adapter.write for: ${file.name}`);
            return Promise.resolve();
        }
        return originals.adapterWrite(path, data);
    };

    // Block view.save if it exists
    if (view && originals.viewSave) {
        view.save = async () => {
            console.log(`🚫 Blocked view.save for: ${file.name}`);
            return Promise.resolve();
        };
    }

    // Also try to prevent the file from being marked as dirty
    if (view && (view as any).editor) {
        const editor = (view as any).editor;
        
        // Override the editor's change handler temporarily
        const originalOnChange = editor.onChange;
        if (originalOnChange) {
            // Store it properly in originals object
            originals.editorOnChange = originalOnChange;
            
            editor.onChange = () => {
                // Do nothing - prevent marking as dirty
                console.log(`🚫 Blocked editor onChange for: ${file.name}`);
            };
        }
    }

    // Set up cleanup
    const cleanup = () => {
        console.log(`🔓 Restoring auto-save for: ${file.name}`);
        
        delete (app as any)[fileKey];
        
        // Restore original methods
        app.vault.modify = originals.vaultModify;
        app.vault.adapter.write = originals.adapterWrite;
        
        if (view && originals.viewSave) {
            view.save = originals.viewSave;
        }
        
        // Restore editor onChange if we modified it
        if (view && (view as any).editor && originals.editorOnChange) {
            (view as any).editor.onChange = originals.editorOnChange;
        }
    };

    // Cleanup when switching away from the file
    const activeLeafChangeRef = app.workspace.on('active-leaf-change', (leaf) => {
        if (!leaf || leaf.view.file?.path !== file.path) {
            cleanup();
            app.workspace.offref(activeLeafChangeRef);
        }
    });

    // Backup cleanup after configured timeout
    setTimeout(() => {
        cleanup();
        app.workspace.offref(activeLeafChangeRef);
    }, CONFIG.protectionTimeout);
}

Setup

We will be using the CodeScript Toolkit plugin by @mnaoumov.

:open_file_folder: Save the script as Block-Auto-Save-on-Files-in-Reading-Mode.ts in the start-up folder given in the settings of Codescript Toolkit.

Add these lines to a main.js file in the same folder:

import { invoke as blockAutoSave } from './Block-Auto-Save-on-Files-in-Reading-Mode.ts';

export async function invoke(app: App): Promise<void> {
  await blockAutoSave(app);
}

It is important you add these files in startup folder so the plugin loads it.
In my case, it looks like this (all in one folder, like pigs in one blanket):

Config section of script

In the meantime, I added variations so you can choose even more than one key – value pair. It doesn’t have to be published files you want protection on, of course. You can use the script for other purposes (the simple case being querying recently modified files, any Bases or Dataview queries or Obsidian Search Modal searches where you filter by New to Old). You can potentially use it for all of your files with regex.

Click to expand for regex details.
How this works:
  1. Set isRegex: true to enable regex matching for that check
  2. Regex patterns can be written with or without surrounding slashes:
  • '/pattern/flags' - traditional regex format with flags
  • 'pattern' - just the pattern without slashes
  1. Works with arrays - for frontmatter like tags: [draft, important], it will test the regex against each array item
  2. Error handling - if the regex is invalid, it logs an error and returns false (doesn’t match)
Example regex patterns:
  • /.*/ - Match anything (any non-empty value)
  • /^published$/ - Exact match for “published”
  • /published/ - Contains “published”
  • /^(draft|published)$/ - Match either “draft” or “published”
  • /important/i - Case-insensitive match for “important”
  • /^temp/ - Match values starting with “temp”
  • /\.draft$/ - Match values ending with “.draft”

So to protect all files with any title, you would use for example:

{ key: 'title', value: '/.*/', isRegex: true }
  • If you don’t have title, you can try with aliases, for example, if you have those in every file you want to track and protect.

Currently, this is active in the script:

        { key: 'status', value: 'published' },

… the other lines are commented out placeholders.

How the script works

What we do here is decouple viewing from writing. This part of the patch is necessary for us to stay protected.

The script…

  • Hooks into Obsidian’s file modification pipeline.
  • It checks frontmatter values on file-open.
  • If the file matches, it aggressively blocks all write/save methods (vault.modify, adapter.write, view.save, editor.onChange).
    • They may not be all needed for our purposed but AI came up with a solution tackling these and the script is not destructive on your files in any case, so you can safely use it.
  • The file is put into “auto-save lockdown” until you switch away or a timeout ends.
How it works in real everyday Obsidianing

When you open any kind of file you wanted to keep from being touched, the script will protect it from any edits or auto-saves for a short time. You’ll see a little lock icon notice when it’s active.
If you in the meantime switch to editing mode with the intent to edit the file, the mtime stamp of the markdown file may/will be modified as Obsidian is now allowed to write to the file.

What else to do?

Nothing.

The plugin will automatically load the script on Obsidian startup (provided you followed that part of the setup properly) and any subsequent opening of files will be monitored by it.

Disclaimer and Heads-Up

This script was developed with the help of AI and hasn’t been fully tested. It’s certainly not equipped to guard against all possible edge cases, especially those introduced by third-party plugins. Still, for my own use case, it offers reasonable protection.

I hope I can consider this write-up/script an early forerunner—one that will be overtaken in time by those whom it may concern.