Querying Across Thousands of .norg Files Without Leaving Neovim
Neorg's built-in query engine (norg.query) searches thousands of .norg files using structured metadata, TODO states, and heading hierarchy — all inside Neovim via the same treesitter parser. Covers metadata format, Lua API, a weekly review example, and scale limits.
25 Feb 2026, 16:32 UTC

The problem: finding signal in a growing knowledge base
You've been using Neorg for months. Your workspace holds hundreds of .norg files — meeting notes, project plans, literature references, daily journals. Each file starts with a metadata block like @document.meta {title: \"Q3 planning\", tags: [\"work\", \"strategy\"]}. Now you need every TODO item tagged active across the entire workspace, grouped by project, for a weekly review. Grep won't understand heading hierarchy or TODO state transitions. External indexers add friction. You want the answer inside Neovim, using the same parser that renders your notes.
How .norg metadata enables structured queries
The .norg format puts a directive-based metadata block at the very top of each file — first non-whitespace content, no exceptions. A leading comment or blank line breaks parsing silently. The block looks like:
@document.meta {
title: \"Weekly sync\",
tags: [\"work\", \"meeting\"],
date: 2026-10-05
}
No YAML front-matter, no separate parser. The Neorg treesitter grammar treats this as part of the AST, so the query engine sees fields, heading levels, property drawers, and TODO states (pending, done, cancelled) as first-class nodes. This is what lets norg.query filter on state: pending and tag: active without false positives from code blocks or prose.
The query API: logical operators, caching, and workspace roots
core.norg.query ships as a core module. Enable it in your neorg.setup:
require('neorg').setup {
load = {
['core.norg.query'] = {},
-- other modules
},
}
The Lua API accepts a table with fields like tags, title, state, and logical combinators and/or/not. Example:
local query = require('neorg.modules.core.norg.query')
local results = query.query({
tags = { 'work' },
state = 'pending',
-- implicit AND across top-level keys
})
-- results: list of { file_path, matched_nodes }
Queries scan recursively from the workspace root configured in neorg.setup. The module caches parsed ASTs per workspace; cold start on ~2,000 files is roughly 300 ms on modern hardware. Cache invalidates on buffer write, but not on external changes (git pull, rsync). Run :Neorg query clear-cache or restart Neovim after such operations.
Worked example: generating a weekly review file
Create a user command that collects every pending TODO tagged active, then writes a new .norg file with the results inserted via neorg.itero (the built-in templating/insertion module). Place this in your Neovim config:
vim.api.nvim_create_user_command('NeorgWeeklyReview', function()
local query = require('neorg.modules.core.norg.query')
local items = query.query({
tags = { 'active' },
state = 'pending',
})
if #items == 0 then
vim.notify('No pending active TODOs found', vim.log.levels.INFO)
return
end
-- Build a .norg document string
local lines = {
'@document.meta {',
' title: \"Weekly review \" .. os.date(\"%Y-%m-%d\"),',
' tags: [\"review\", \"generated\"]',
'}',
'',
'* Pending active items',
'',
}
for _, item in ipairs(items) do
table.insert(lines, string.format('- [[file:%s][%s]]', item.file_path, item.title or 'untitled'))
end
local review_path = vim.fn.stdpath('data') .. '/neorg/reviews/weekly-' .. os.date('%Y-%m-%d') .. '.norg'
vim.fn.mkdir(vim.fn.fnamemodify(review_path, ':h'), 'p')
vim.fn.writefile(lines, review_path)
vim.cmd('edit ' .. vim.fn.fnameescape(review_path))
end, { desc = 'Generate weekly review from active TODOs' })
Run :NeorgWeeklyReview from any Neorg buffer. The command queries the workspace, writes a dated review file under stdpath('data')/neorg/reviews/, and opens it. You can then refine, add context, or feed it into neorg.presenter for a slide deck.
Trade-offs and limitations to plan for
- API stability: The Lua API is not semver-stable. Pin the Neorg commit in your plugin manager (e.g.,
{ 'nvim-neorg/neorg', commit = 'abc1234' }) if reproducibility matters. - Tag discipline: Tags are case-sensitive strings with no built-in hierarchy or aliasing. Adopt a convention early (
project/foo,area/work) and enforce it with a snippet or linter. - No cross-workspace queries: Each Neorg workspace is an isolated root. If you need unified search, merge workspaces at the filesystem level or symlink them under a common parent.
- Cache gotchas: External file changes don't invalidate the cache. Add
:Neorg query clear-cacheto your post-merge git hook or run it manually aftergit pull. - Large literate-programming files: Big code blocks inflate AST memory. Split heavy files into separate modules or use
@document.meta {large: true}(convention) to flag them for separate handling.
Getting started and verifying it works
- Ensure Neorg v8+ is installed and
core.norg.queryis loaded. - Create a test file
test.norgin your workspace with the metadata block above and a TODO item:- [ ] tagged active. - In Neovim, run
:lua print(vim.inspect(require('neorg.modules.core.norg.query').query({ tags = { 'active' }, state = 'pending' }))). You should see a table with your file path and matched node. - Install
nvim-neorg/neorg-telescopeand try:Telescope neorg queryfor interactive fuzzy search over saved queries. - Profile cache behavior with
:Neorg query statsbefore and after editing a tracked file.
If the test query returns empty, check that the metadata block is the very first content (no leading newline) and that tags match case exactly. The Neorg changelog (GitHub releases for nvim-neorg/neorg) documents query API changes between versions — review it when upgrading.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.