Markdown database: query and update Markdown files with IWE
A Markdown database is a folder of .md files that you query like a table. Each file is a row, its path is the primary key, the YAML frontmatter holds the columns, and links between files are the relations. IWE reads the folder directly, so there is no import step and no second copy of your data: iwe find, iwe count, iwe update and iwe delete run filters over the files, and iwe schema validate checks them against a schema.
This guide builds a small task database from scratch and runs every operation on it. For the long-form reasoning behind the idea, read Markdown as a database. For an overview of the query language and what it can do, see Your notes become a database.
How database concepts map to Markdown
| Database concept | In a Markdown database | Example |
|---|---|---|
| Table row | One Markdown file | tasks/analytics.md |
| Primary key | The key: the file path relative to the workspace root, without .md | tasks/analytics |
| Columns | Frontmatter fields | status: review, priority: 5 |
| Containment relation | An inclusion link: a link alone on its own line | [Set up analytics](../tasks/analytics) |
| Reference relation | An inline link inside a sentence | Draft the copy with [Alice](../people/alice). |
| Schema | A document schema in .iwe/schemas/, bound to keys in .iwe/config.toml | .iwe/schemas/task.yaml |
| Query and CRUD | One filter language shared by find, count, update and delete | iwe find --filter 'status: draft' |
Folders only group files on disk. A document belongs to a project because the project includes it, not because of the folder it sits in.
Prerequisites
- IWE installed and on your
PATH(see Installation). The output below comes fromiwe0.25.0. - A terminal. Everything happens in a new, empty folder, so you can follow along without touching your notes.
Step 1: create the workspace
Make a folder and initialize it:
mkdir tasks-db && cd tasks-db
iwe init
0 files here
initialized .iwe/config.toml
try: iwe normalize --dry-run · iwe find --fuzzy <query>
Step 2: add a few rows
Create five files. Three tasks share the same columns, a project includes them, and a person is referenced from one task.
projects/launch.md:
---
type: project
status: active
---
# Website launch
[Write the landing page](../tasks/landing-page)
[Set up analytics](../tasks/analytics)
[Publish the docs](../tasks/docs)
tasks/landing-page.md:
---
type: task
status: draft
priority: 8
tags: [design, copy]
---
# Write the landing page
Draft the hero copy with [Alice](../people/alice).
tasks/analytics.md:
---
type: task
status: review
priority: 5
tags: [ops]
---
# Set up analytics
Pick a privacy-friendly tracker.
tasks/docs.md:
---
type: task
status: done
priority: 3
tags: [copy]
---
# Publish the docs
Sync the reference pages.
people/alice.md:
---
type: person
role: designer
---
# Alice
The three links in projects/launch.md each sit alone on a line, so they are inclusion links: the tasks belong to the project. The link to Alice sits inside a sentence, so it is a reference.
Step 3: list every row
iwe find with no arguments returns every document as a one-line index. Arrows show the relations and the dots list the columns:
iwe find
- [Alice](people/alice) <- [Write the landing page](tasks/landing-page) · type: person · role: designer
- [Set up analytics](tasks/analytics) <- [Website launch](projects/launch) · type: task · status: review · priority: 5 · tags: ops
- [Publish the docs](tasks/docs) <- [Website launch](projects/launch) · type: task · status: done · priority: 3 · tags: copy
- [Write the landing page](tasks/landing-page) -> [Alice](people/alice) <- [Website launch](projects/launch) · type: task · status: draft · priority: 8 · tags: design, copy
- [Website launch](projects/launch) -> [Set up analytics](tasks/analytics), [Publish the docs](tasks/docs), [Write the landing page](tasks/landing-page) · type: project · status: active
To see the columns your files actually use, run iwe schema. It scans the frontmatter and reports types, coverage and values:
iwe schema --filter 'type: task'
| Field | Types | Coverage | Distinct | Values |
| -------- | ------------- | -------- | -------- | --- |
| priority | number (100%) | 3 (100%) | 3 | 3 (1), 5 (1), 8 (1) |
| status | string (100%) | 3 (100%) | 3 | done (1), draft (1), review (1) |
| tags | array (100%) | 3 (100%) | 0 | --- |
| type | string (100%) | 3 (100%) | 1 | task (3) |
Step 4: query by frontmatter
--filter takes a YAML filter in the style of MongoDB. Top-level fields are combined with AND, and $-prefixed keys are operators. This query returns tasks that are still in draft or review:
iwe find --filter 'type: task, status: { $in: [draft, review] }'
- [Set up analytics](tasks/analytics) <- [Website launch](projects/launch) · type: task · status: review · priority: 5 · tags: ops
- [Write the landing page](tasks/landing-page) -> [Alice](people/alice) <- [Website launch](projects/launch) · type: task · status: draft · priority: 8 · tags: design, copy
To choose the columns and the order, add --project and --sort. $title is the document title, and priority:-1 sorts in descending order:
iwe find --filter 'type: task' --project 'title=$title,status,priority' --sort priority:-1
- [Write the landing page](tasks/landing-page) · status: draft · priority: 8
- [Set up analytics](tasks/analytics) · status: review · priority: 5
- [Publish the docs](tasks/docs) · status: done · priority: 3
For scripts and agents, switch the output to JSON with -f json:
iwe find --filter 'priority: { $gte: 5 }' --project 'key=$key,status,priority' -f json
[
{
"key": "tasks/analytics",
"status": "review",
"priority": 5
},
{
"key": "tasks/landing-page",
"status": "draft",
"priority": 8
}
]
The query language reference lists every operator, including $ne, $nin, $exists, $or and $nor.
Step 5: query by relations
Relations work like joins. --included-by follows inclusion links down from a document, and you can combine it with a frontmatter filter. These are the open tasks in the launch project:
iwe find --included-by projects/launch --filter 'status: { $ne: done }'
- [Set up analytics](tasks/analytics) <- [Website launch](projects/launch) · type: task · status: review · priority: 5 · tags: ops
- [Write the landing page](tasks/landing-page) -> [Alice](people/alice) <- [Website launch](projects/launch) · type: task · status: draft · priority: 8 · tags: design, copy
--references follows inline links. These are the documents that mention Alice:
iwe find --references people/alice
- [Write the landing page](tasks/landing-page) -> [Alice](people/alice) <- [Website launch](projects/launch) · type: task · status: draft · priority: 8 · tags: design, copy
Add :0 to an anchor, as in --included-by projects/launch:0, to walk the whole tree instead of direct children only. See iwe find for all the flags.
Step 6: count rows
iwe count takes the same filters and prints a single number:
iwe count
iwe count --filter 'type: task, status: { $ne: done }'
5
2
Because the output is just an integer, iwe count fits checks in scripts and CI. See iwe count.
Step 7: update rows
iwe update --set writes frontmatter fields on every document the filter matches, and --unset removes them. Preview first with --dry-run:
iwe update --filter 'status: review' --set owner=alice --set due=2026-12-01 --dry-run
Would update 1 document(s)
Then run it for real:
iwe update --filter 'status: review' --set owner=alice --set due=2026-12-01
Updated 1 document(s)
The new columns are now in the file:
---
type: task
status: review
priority: 5
tags:
- ops
owner: alice
due: 2026-12-01
---
# Set up analytics
Pick a privacy-friendly tracker.
To protect against a filter that matches more than you meant, add --expect with the number of documents you expect to change. If the count is wrong, nothing is written:
iwe update --filter 'type: task, priority: { $gte: 5 }' --set status=review --expect 1
error: update expects 1 document, matched 2
tasks/analytics › Set up analytics
tasks/landing-page › Write the landing page
hint: adjust the filter or raise expect
update and delete refuse to run without a filter or key, so you cannot change the whole folder by accident. See iwe update for block edits and body replacement.
Step 8: delete rows
iwe delete removes the matched files and cleans up links to them. The dry run shows which documents would be deleted and which would be edited:
iwe delete --filter 'status: done' --dry-run
Would delete 'tasks/docs'
Would update 1 document(s)
projects/launch
iwe delete --filter 'status: done' --expect 1
Deleting 'tasks/docs'
Updated 1 document(s)
The inclusion link in the project is gone, so no broken links remain:
---
type: project
status: active
---
# Website launch
[Write the landing page](../tasks/landing-page)
[Set up analytics](../tasks/analytics)
See iwe delete for how inline references are turned into plain text.
Step 9: validate with a schema
Frontmatter is schemaless until you add a schema. Create .iwe/schemas/task.yaml. The frontmatter part is standard JSON Schema:
frontmatter:
type: object
required: [status, priority]
properties:
status: { enum: [draft, review, done] }
priority: { type: integer, minimum: 1, maximum: 10 }
Bind it to every document under tasks/ by adding this to .iwe/config.toml, after the existing [schemas] line:
[schemas.task]
match = "tasks/**"
Run the validator. When every document passes, it prints nothing and exits with code 0:
iwe schema validate
Now break a row on purpose and validate again:
iwe update -k tasks/analytics --set status=blocked
iwe schema validate
Updated 1 document(s)
tasks/analytics › frontmatter › status: "blocked" is not one of "draft", "review" or "done"
The validator exits with code 1, so you can run it in CI or have an agent fix what it reports. Add -f json for a machine-readable report. Schemas can also check sections, headers and size; see Document schema.
Summary
You built a Markdown database without a database server: files as rows, keys as primary keys, frontmatter as columns, and links as relations. One filter language reads, counts, updates and deletes them, and a schema keeps the columns consistent. Your files stay plain Markdown, so any editor can open them and git diff shows exactly what each command changed.
Next steps
- Query language: every filter operator, graph operator and projection.
iwe find: search, filters, sorting and output formats.iwe update: frontmatter and block edits with guards.- Document schema: validate frontmatter and document structure.
- Keys: how file paths become document keys.
- Inclusion links: how a link on its own line builds the hierarchy.
From the blog
- Markdown database: notes as memory for humans and AI agents Treat a folder of Markdown notes as a queryable graph database: file-path keys, a frontmatter schema, inclusion and reference edges, and one filter language.