In this tutorial you will turn a directory of three tiny markdown files into a SQL database. You will:
- Create the directory and files.
- Query them straight away with zero configuration and no code.
- Declare your own named table — with a tiny parser that pulls a column out of each file — and query it.
It takes about five minutes.
dirsql only ever reads your files — it never writes, moves, or changes
them — so it is safe to point at a real directory of your own once you are
done here. See Read-only by design.
You need: a terminal with jq, and Node ≥ 20.11
(for npx). Every npx dirsql step below also has a uvx tab that behaves
identically, if you prefer Python tooling
(uv).
Paste this whole block into your terminal. It makes a working directory with a subfolder per note author and writes three tiny markdown notes:
mkdir -p my-notes/notes/alice my-notes/notes/bob
cd my-notes
cat > notes/alice/welcome.md <<'EOF'
# Welcome
Start here. This folder is about to become a database.
EOF
cat > notes/alice/ideas.md <<'EOF'
# Ideas
- query files with SQL
- watch for changes
EOF
cat > notes/bob/reading-list.md <<'EOF'
# Reading list
- The SQLite file format
EOF(Any directory of files works with dirsql — the rest of this tutorial
assumes exactly these three so your output matches ours.)
Check that all three files are in place:
find notes -type f | sortnotes/alice/ideas.md
notes/alice/welcome.md
notes/bob/reading-list.md
You wrote no configuration and no schema. Ask dirsql how many files are in
this directory anyway — from inside my-notes, run one command:
::: code-group
npx dirsql "SELECT COUNT(*) AS files FROM './**'"uvx dirsql "SELECT COUNT(*) AS files FROM './**'":::
The first run downloads the package (npx asks for confirmation — answer
y; uvx prints download progress), then prints the result:
files
-----
3
1 row
Three files, three rows. That one command scanned the directory, handed SQLite one row per file, ran your SQL, and printed the answer.
There is no named table here — you never declared one. './**' is a
path-table: a quoted path written where a table
name goes. './**' means every file under the directory you ran the command
in, at any depth; './' alone would list only the files directly inside it,
like ls. The path is the query.
At a terminal dirsql prints a table; when its output is piped, it prints a
JSON array of row objects instead. From here on we pipe it through jq to
pretty-print. Ask for two columns instead of a count:
::: code-group
npx dirsql query "SELECT path, size FROM './**' ORDER BY path" | jquvx dirsql query "SELECT path, size FROM './**' ORDER BY path" | jq:::
[
{
"path": "notes/alice/ideas.md",
"size": 52
},
{
"path": "notes/alice/welcome.md",
"size": 66
},
{
"path": "notes/bob/reading-list.md",
"size": 41
}
]path and size are two of the built-in file columns dirsql collects for
every file — see stat columns for the
full list. (The size values are byte counts; they match the output above
because you pasted the files exactly.)
You have a working SQL database over your files, and you never left the command line.
A declared table fixes a shape once: you give it a name, scope it to exactly the files you care about, and then query it by name instead of repeating a path in every question. It is also the on-ramp to everything a path-table can't do — a named table can be kept live by the watcher, persisted across restarts, and given a parser that reads inside your files.
That parser is the point: a named table's columns are exactly what its
on-file command emits. dirsql adds nothing on its own — so this is where
you pull a value out of each file. Still inside my-notes, write a tiny
parser that reads a note's title line (the # Heading) and its author (the
folder name), and prints them as a JSON row, one object per line:
cat > note.sh <<'EOF'
#!/usr/bin/env sh
for f; do
title=$(sed -n 's/^# //p' "$f" | head -n1)
author=$(basename "$(dirname "$f")")
jq -cn --arg t "$title" --arg a "$author" '{title: $t, author: $a}'
done
EOFNow create a .dirsql.toml that points a table at it:
cat > .dirsql.toml <<'EOF'
[[table]]
name = "notes"
ddl = "CREATE TABLE notes (title TEXT, author TEXT)"
glob = "notes/**/*.md"
on-file = "sh note.sh"
EOFThree keys define the table:
globselects which files feed the table — every.mdat any depth undernotes/, relative to the directory the config sits in.ddlis ordinaryCREATE TABLESQL naming the columns you want to keep.on-fileis the command run once for the table, with every matched file's path appended as an argument; the JSON objects it prints, one per line, are the table's rows. The columns are exactly what it emits —titlefrom the heading,authorfrom the folder.
dirsql does not auto-load a .dirsql.toml from the current directory, so
pass it explicitly with -c, after the SQL:
::: code-group
npx dirsql "SELECT title, author FROM notes ORDER BY author, title" -c .dirsql.toml | jquvx dirsql "SELECT title, author FROM notes ORDER BY author, title" -c .dirsql.toml | jq:::
[
{
"author": "alice",
"title": "Ideas"
},
{
"author": "alice",
"title": "Welcome"
},
{
"author": "bob",
"title": "Reading list"
}
]You queried FROM notes by name — no path, no glob to repeat — and title
came from inside each file, something a path-table can't reach. Because
author is a real SQL column, you can aggregate on it. Count each author's
notes:
::: code-group
npx dirsql query "SELECT author, COUNT(*) AS notes FROM notes GROUP BY author ORDER BY author" -c .dirsql.toml | jquvx dirsql query "SELECT author, COUNT(*) AS notes FROM notes GROUP BY author ORDER BY author" -c .dirsql.toml | jq:::
[
{
"author": "alice",
"notes": 2
},
{
"author": "bob",
"notes": 1
}
]That's the whole loop: files in a directory, an instant query with no configuration, and a declared table when you want a named shape to reuse.
- Query files without a config — more path-table questions you can ask with no setup at all.
- Define tables for your files — the full
[[table]]recipe: multiple tables, each with its ownon-fileparser. - Extract rows from file contents —
pull columns out of inside your files with an
on-fileparser. - CLI — every flag, plus running
dirsqlas a long-lived HTTP server instead of one-shot queries. - HTTP API —
POST /query, plusGET /events, a live stream of row changes as files change. - SDK — embed
dirsqlin a Python, Rust, or TypeScript program instead of running the CLI. - Why is the database rebuilt from your files on every query? See
how
dirsqlthinks.