Your first dirsql database
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).
1. Create three files
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.md2. Query your files
You wrote no configuration and no schema. Ask dirsql how many files are in this directory anyway — from inside my-notes, run one command:
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}]Three files, three rows. That one command scanned the directory, handed SQLite one row per file, ran your SQL, and printed the answer as JSON.
There is no named table here — you never declared one. './' is a path-table: a quoted path written where a table name goes. './' means everything under the directory you ran the command in. The path is the query.
3. Select some columns
The response is always a JSON array of row objects, so from here on we pipe it through jq to pretty-print. Ask for two columns instead of a count:
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.
4. Declare a table
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:
cat > note.sh <<'EOF'
#!/usr/bin/env sh
title=$(sed -n 's/^# //p' "$1" | head -n1)
author=$(basename "$(dirname "$1")")
printf '[{"title":%s,"author":%s}]' \
"$(jq -Rn --arg t "$title" '$t')" \
"$(jq -Rn --arg a "$author" '$a')"
EOFNow create a .dirsql.toml that points a table at it:
cat > .dirsql.toml <<'EOF'
[[table]]
ddl = "CREATE TABLE notes (title TEXT, author TEXT)"
glob = "notes/**/*.md"
on-file = "sh note.sh {path}"
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 per matched file;{path}is the file's path, and its printed JSON row becomes the file's row. The columns are exactly what it emits —titlefrom the heading,authorfrom the folder.
5. Query the table
dirsql does not auto-load a .dirsql.toml from the current directory, so pass it explicitly with -c, after the SQL:
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:
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.
Where to go next
- 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.