Practice Lab

SQLite Is Enough

ToolJuly 31, 2026 · David J.S. Madgett · 13 min read

I have written that a practice management system is a relational database, some views, and an API, and that the firm’s institutional knowledge belongs in a folder you own. This article is about the third thing, which sits between them: the place structured data goes when Markdown files stop being the right shape for it.

The answer, for a small firm, is one SQLite file. Not Postgres, not MySQL, not a hosted database service with a connection string and a monthly bill. A file.

That recommendation gets a specific kind of pushback — that SQLite is a toy, a starter database, something you outgrow. It is not. It is the most widely deployed database engine in existence, it is in the browser you are reading this in, and the constraints that would push you off it are constraints a solo or three-lawyer practice does not have. What it does have is an ops budget of zero, which is the actual binding constraint and the one nobody designs for.

Every example below is invented — fictional matters, fictional captions, a fictional file number. No client data appears in this article. I ran every SQL statement and every Python snippet here against SQLite 3.51.0 through Python’s standard-library sqlite3 module before publishing, and I flag the places where I am relying on documented behavior rather than a test.

Why local-first fits a law practice specifically

Four properties of the work, and each of them points the same way.

The concurrency is one lawyer. Not one lawyer at a time — one lawyer, plus some background jobs that index a folder or pull a calendar overnight. The workload SQLite is genuinely bad at, many simultaneous writers, does not exist here.

The working set is small. A practice with several hundred matters, tens of thousands of documents, and a decade of events is a database measured in hundreds of megabytes. Queries that would need careful indexing at a hundred million rows run instantly at a hundred thousand.

The backup story is “copy a file.” Not a dump, not a replication topology, not a point-in-time recovery procedure you have never tested. One file, plus one caveat I will come back to.

The data is confidential. This is the one that changes the calculus rather than merely simplifying it. A database on your own machine has no network listener, no credentials to leak, no vendor employee with production access, and no breach-notification path that runs through somebody else’s incident response. The disclosure surface is the disk and the person holding the laptop.

Minn. R. Prof. Conduct 1.6(c) requires a lawyer to “make reasonable efforts to prevent the inadvertent or unauthorized disclosure of, or unauthorized access to, information relating to the representation of a client,” and Comment [17] lists the factors that go into “reasonable” — among them “the sensitivity of the information,” “the likelihood of disclosure if additional safeguards are not employed,” “the cost of employing additional safeguards,” and “the difficulty of implementing the safeguards.” I quote it because cost and difficulty are the factors a technical decision actually moves, and a design with fewer components to secure is cheaper to secure. That is an observation about architecture, not ethics advice; every lawyer answers the rest in their own jurisdiction.

Against all that, running a database server buys you: nothing you need, and a service to patch, back up, monitor, and explain to whoever inherits your practice.

The schema

Here is a real starting schema for what I would call a practice brain — a queryable layer over matters, documents, what happened, and what is owed. It is not a practice management system and it is not where your trust ledger goes. It is the layer that lets you ask questions.

CREATE TABLE matters (
    id          INTEGER PRIMARY KEY,
    file_no     TEXT    NOT NULL UNIQUE,
    caption     TEXT    NOT NULL,
    status      TEXT    NOT NULL DEFAULT 'open'
                CHECK (status IN ('open','stayed','closed')),
    opened_on   TEXT    NOT NULL,
    closed_on   TEXT
);

CREATE TABLE documents (
    id          INTEGER PRIMARY KEY,
    matter_id   INTEGER NOT NULL REFERENCES matters(id) ON DELETE CASCADE,
    rel_path    TEXT    NOT NULL,
    title       TEXT    NOT NULL,
    kind        TEXT    NOT NULL DEFAULT 'other',
    doc_date    TEXT,
    sha256      TEXT,
    body        TEXT,                       -- extracted / OCR'd text
    added_at    TEXT    NOT NULL DEFAULT (datetime('now')),
    UNIQUE (matter_id, rel_path)
);
CREATE INDEX documents_by_matter ON documents(matter_id, doc_date);

CREATE TABLE observations (
    id           INTEGER PRIMARY KEY,
    matter_id    INTEGER REFERENCES matters(id) ON DELETE CASCADE,
    document_id  INTEGER REFERENCES documents(id) ON DELETE SET NULL,
    kind         TEXT    NOT NULL,          -- posture | opposing | deadline | note
    body         TEXT    NOT NULL,
    source       TEXT    NOT NULL,          -- where this came from
    observed_at  TEXT    NOT NULL,          -- when it was true
    recorded_at  TEXT    NOT NULL DEFAULT (datetime('now')),  -- when we learned it
    supersedes   INTEGER REFERENCES observations(id)
);
CREATE INDEX observations_by_matter ON observations(matter_id, observed_at);

CREATE TABLE tasks (
    id           INTEGER PRIMARY KEY,
    matter_id    INTEGER REFERENCES matters(id) ON DELETE CASCADE,
    title        TEXT    NOT NULL,
    due_on       TEXT,
    status       TEXT    NOT NULL DEFAULT 'open'
                 CHECK (status IN ('open','done','cancelled')),
    created_at   TEXT    NOT NULL DEFAULT (datetime('now')),
    completed_at TEXT
);
CREATE INDEX tasks_open_by_due ON tasks(due_on) WHERE status = 'open';

A few choices worth defending. Dates are TEXT in ISO-8601 (2026-08-15) because SQLite has no date type and ISO-8601 strings sort and compare correctly as text — this is the documented, intended approach, not a hack. CHECK constraints on status columns are free and catch the typo that would otherwise silently produce a matter in state 'Open' that never appears in a report again. tasks_open_by_due is a partial index: it only covers open tasks, which is the only kind you ever query by due date, and it stays small forever.

The observation log is append-only

observations is the table that makes this worth building, and it works differently from the others. You do not update an observation — you insert a new one, set supersedes if it replaces an earlier one, and leave the old row alone. The table only grows.

The reason is that in a practice, the record of what you knew and when you knew it is itself important. A row you overwrote is a fact you destroyed. An append-only log lets you ask the question a mutable table cannot answer:

-- What did this file look like as of March 15?
SELECT observed_at, kind, body
  FROM observations
 WHERE matter_id = 1 AND observed_at <= '2026-03-15'
 ORDER BY observed_at;

Against the fictional data I loaded, that returned the two observations recorded before that date and correctly excluded the April one — the state of knowledge on the day, not the state of knowledge now.

The two timestamps are the other half of it. observed_at is when the fact was true; recorded_at is when it entered the system. Those differ constantly — you learn on Thursday about something that happened three weeks ago — and keeping them separate is the difference between a log you can reason about and a log that quietly rewrites history every time you backfill.

Current state is then a query rather than a column:

SELECT kind, body, observed_at FROM (
    SELECT kind, body, observed_at,
           ROW_NUMBER() OVER (
               PARTITION BY kind ORDER BY observed_at DESC, id DESC) AS rn
      FROM observations WHERE matter_id = 1
) WHERE rn = 1;

Window functions have been in SQLite since 3.25. That query returned the latest posture note and the latest opposing-counsel note and nothing else, which is exactly the “where does this stand” summary you want on a matter page.

Open the connection correctly

Three pragmas, and two of them are not defaults.

import sqlite3

def connect(path):
    conn = sqlite3.connect(path, timeout=30.0)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode = WAL")     # persistent: set once, stays
    conn.execute("PRAGMA foreign_keys = ON")      # per-connection, OFF by default
    conn.execute("PRAGMA busy_timeout = 5000")
    conn.execute("PRAGMA synchronous = NORMAL")   # safe under WAL
    return conn

foreign_keys is off by default and is per-connection. I checked: a fresh sqlite3.connect reports PRAGMA foreign_keys = 0. Every REFERENCES clause in the schema above is decorative until you turn it on, on every connection, including the one your background job opens at 3 a.m. With it on, inserting a document against a nonexistent matter raised IntegrityError: FOREIGN KEY constraint failed, which is the whole point.

WAL mode is what makes a background job survivable. In the default rollback journal, a writer blocks readers. In WAL, readers and one writer proceed concurrently. I tested this directly: with one connection holding an open BEGIN IMMEDIATE transaction and an uncommitted insert, a second connection read the table normally, saw the committed row count, and did not see the uncommitted row. That is the situation you are actually in when an indexer is walking your document folder while you run a query.

busy_timeout is what you set instead of losing. SQLite allows one writer at a time; a second writer that cannot get the lock retries until the timeout expires and then raises. In my test, a second connection with a 500 ms timeout failed with OperationalError: database is locked — which is correct behavior, and which is what you will see in production if a background writer holds a transaction longer than your patience. Set the timeout generously and keep write transactions short.

WAL creates two sidecar files, -wal and -shm, alongside your database. They matter for backup, below.

FTS5 is the feature you are not using

This is the most underused thing in SQLite for this use case, and it is the reason a homemade practice brain can do something your practice management system cannot: search the contents of your documents, ranked, in milliseconds, offline.

Create an external-content FTS5 table — one that indexes another table’s columns without duplicating the text:

CREATE VIRTUAL TABLE documents_fts USING fts5(
    title, body,
    content='documents',
    content_rowid='id',
    tokenize='porter unicode61'
);

content='documents' means the index stores no copy of your text; it points at the base table. porter adds English stemming, so a search for allege finds alleges and allegation. In my test it found both.

External-content tables do not update themselves. You keep them in sync with triggers, and the delete syntax is the part everyone gets wrong — you insert a special 'delete' command row rather than issuing a DELETE:

CREATE TRIGGER documents_ai AFTER INSERT ON documents BEGIN
    INSERT INTO documents_fts(rowid, title, body)
    VALUES (new.id, new.title, new.body);
END;

CREATE TRIGGER documents_ad AFTER DELETE ON documents BEGIN
    INSERT INTO documents_fts(documents_fts, rowid, title, body)
    VALUES ('delete', old.id, old.title, old.body);
END;

CREATE TRIGGER documents_au AFTER UPDATE ON documents BEGIN
    INSERT INTO documents_fts(documents_fts, rowid, title, body)
    VALUES ('delete', old.id, old.title, old.body);
    INSERT INTO documents_fts(rowid, title, body)
    VALUES (new.id, new.title, new.body);
END;

The 'delete' row must carry the old column values, because FTS5 uses them to work out which index entries to remove. Pass the new values and the index silently rots.

I tested the rot case and the correct case. After updating a document’s body, a search for a word only in the old text returned 0 rows and a word only in the new text returned 1. After deleting a document, a search for a word unique to it returned 0. And SQLite will tell you if you got it wrong:

INSERT INTO documents_fts(documents_fts) VALUES ('integrity-check');

That statement raises if the index disagrees with the base table. Run it in your nightly job. It is the cheapest data-quality check in the system.

Now the query. bm25() returns a relevance score — more negative is better, which trips everyone up, so ORDER BY rank ascending is correct. The extra arguments are per-column weights:

SELECT d.rel_path, m.file_no, d.title,
       snippet(documents_fts, 1, '[', ']', ' … ', 12) AS hit,
       bm25(documents_fts, 2.0, 1.0) AS rank
  FROM documents_fts
  JOIN documents d ON d.id = documents_fts.rowid
  JOIN matters   m ON m.id = d.matter_id
 WHERE documents_fts MATCH ?
 ORDER BY rank
 LIMIT 10;

bm25(documents_fts, 2.0, 1.0) weights a hit in title twice as heavily as one in body. snippet() returns the matching text with the hit bracketed — column index 1 is body, and the last argument is the token budget.

The query language is richer than most people expect. All of these ran against my fictional corpus:

Query string What it does Result in my test
discovery OR motion boolean matched the scheduling order
"consumer reporting agency" exact phrase matched the complaint only
NEAR(dispositive motion, 5) terms within 5 tokens matched the scheduling order
allege porter stemming matched alleges and allegation
title : order single-column search matched Scheduling Order by title alone
sched* prefix match matched Scheduling

The snippet returned for the phrase query was:

Plaintiff alleges the defendant furnished inaccurate information
to a [consumer reporting agency].

That is ranked full-text search over your document corpus, with highlighted context, on a laptop, with no service running and no data leaving the building — perhaps two hundred lines of code from nothing to working.

One caveat I did not test at scale: FTS5 indexes a scanned OCR layer as faithfully as it indexes clean text, so a bad OCR pass produces a confidently useless index. Index quality is upstream of the database.

Migrations without a framework

You will change this schema. Do it with PRAGMA user_version, an integer SQLite stores in the file header and otherwise ignores.

MIGRATIONS = []

def migration(fn):
    MIGRATIONS.append(fn)
    return fn

@migration
def m001_core(conn):
    conn.executescript(""" ... the CREATE TABLEs above ... """)

@migration
def m002_fts(conn):
    conn.executescript(""" ... the FTS5 table and triggers ... """)

def migrate(conn):
    current = conn.execute("PRAGMA user_version").fetchone()[0]
    for i, step in enumerate(MIGRATIONS, start=1):
        if i <= current:
            continue
        with conn:                       # one transaction per step
            step(conn)
            conn.execute(f"PRAGMA user_version = {i}")

Run it on every startup. On a fresh file it went 0 → 3 and applied all three steps; run immediately again it applied nothing. Each step is atomic — if step three raises, steps one and two are committed, user_version is 2, and rerunning resumes at three.

Two rules. Never edit a migration that has shipped — add a new one. And note that PRAGMA user_version cannot be parameterized: PRAGMA user_version = ? raises OperationalError: near "?": syntax error, so that one value gets interpolated, and it had better be an integer you control.

Backup: VACUUM INTO, not cp

A naive cp of a live SQLite database can produce a corrupt copy, because the file can be mid-write and because in WAL mode the committed state is split between the database file and the -wal file. SQLite’s own corruption documentation is explicit: a background process that copies the file mid-transaction produces a backup that “might contain some old and some new content, and thus be corrupt.” Copying is safe only when no transaction is in progress, and then only if you take the -wal file along with it. That is a precondition you cannot verify from a cron job.

The one-liner:

VACUUM INTO '/path/to/snapshot-2026-07-31.db';

That runs inside a transaction, takes a consistent view of the database, and writes a fresh, defragmented, single-file copy — no -wal, no -shm, nothing to forget. I ran it against a live database with a second connection attached, then opened the snapshot: PRAGMA integrity_check returned ok, user_version was preserved at 3, the row counts matched, and the FTS5 index worked in the copy. The snapshot’s journal mode was delete rather than WAL, which is the point — it is a plain, portable, single file.

From Python, the backup API does the same job and can run incrementally:

dest = sqlite3.connect("/path/to/snapshot.db")
with dest:
    conn.backup(dest)          # safe against a live source
dest.close()

Either one, on a schedule, to a location that is not the same disk. Then restore one and open it, because a backup you have not restored is a hypothesis.

A trap worth showing

Here is a bug I hit writing the examples for this article, because it is the single most common SQL mistake in this kind of dashboard query.

-- WRONG: fans out
SELECT m.file_no,
       COUNT(DISTINCT d.id) AS docs,
       SUM(CASE WHEN t.status='open' THEN 1 ELSE 0 END) AS open_tasks
  FROM matters m
  LEFT JOIN documents d ON d.matter_id = m.id
  LEFT JOIN tasks     t ON t.matter_id = m.id
 GROUP BY m.id;

Joining two one-to-many tables to the same parent produces the cross product. My fictional matter had 3 documents and 2 open tasks; that query reported open_tasks: 6. The COUNT(DISTINCT) on documents hid half the problem, which is worse than if it had been wrong too. Correlated subqueries are slightly less elegant and actually correct:

CREATE VIEW open_matter_load AS
SELECT m.file_no, m.caption,
       (SELECT COUNT(*) FROM documents d WHERE d.matter_id = m.id) AS docs,
       (SELECT COUNT(*) FROM tasks t
         WHERE t.matter_id = m.id AND t.status='open') AS open_tasks,
       (SELECT MIN(t.due_on) FROM tasks t
         WHERE t.matter_id = m.id AND t.status='open') AS next_due,
       (SELECT MAX(o.observed_at) FROM observations o
         WHERE o.matter_id = m.id) AS last_seen
  FROM matters m
 WHERE m.status = 'open';

That version returned 3 and 2. The general lesson: a dashboard number that is wrong is more dangerous than a dashboard you do not have, and aggregate queries over joins are where wrong numbers come from. Check them against a hand count on a small dataset before you trust them on a large one.

When SQLite is the wrong answer

I would rather say this plainly than have you find out later.

Multiple concurrent writers across machines. SQLite allows one writer at a time and relies on the filesystem to implement locking correctly. Over a network share — SMB, NFS, or a consumer sync folder — that assumption does not hold. SQLite’s documentation names network filesystems and NFS specifically, and says that if two processes access the database at the same time on a filesystem with buggy locking, “database corruption might result.” The failure mode is a corrupt file rather than an error message. If two people’s laptops need to write to the same database, you need a server. This is a hard line, not a tuning problem.

A team that needs shared server-side access. If the answer to “who can query this” is more than the people sitting at that machine, you are building a service, and a service wants a database designed for one.

A hosted multi-tenant product. If you are selling software to other firms and their data lives on your infrastructure, this article does not apply to you.

Very large binary payloads. Keep PDFs on the filesystem and store the path and hash. The database indexes the text; the folder holds the bytes. That division is what makes the one-folder architecture and the database complement each other instead of competing.

Anything with a bright-line compliance obligation. The trust-accounting caution from the earlier article stands without qualification. This is not where your client-funds ledger goes.

Outside those, a single file will carry a small firm further than you expect, and the day you outgrow it you will know — a specific thing will stop working, rather than a vague feeling that you should have chosen something more serious.

The point

Every hour spent administering infrastructure produced nothing for a client and has to be recovered somewhere in what an hour of your time costs. That cost is the floor under legal services, and the floor is what prices people out.

A database that requires no administration is not a compromise. It is the correct engineering answer to the actual problem, and it happens to be the cheap one. It is one file. Start it today, and put the first VACUUM INTO on a schedule before you put anything real in it.


Sources

General commentary on practice management and tooling. Not legal advice and not ethics advice. No client information appears in this article — every matter, caption, file number, document, and observation above is invented. Questions about any of this: Send us a message or call 612-470-6529.

words
2,764
sections
11
code_blocks
14
sources
10
distinctive_terms
sqlite · database · backup · index · writer
Pass it onLinkedInX

Get new articles as they land

One email when something new is published here. No course, no upsell — the Practice Lab stays free either way.

Used only to send Practice Lab posts. Unsubscribe from any email. Subscribing does not create an attorney–client relationship.

The only thing we ask

If something here saves you time, spend some of it on people who could not otherwise afford you.

Everything in the Practice Lab is free. No signup, no subscription, no donations — just take a case you would otherwise have to turn down on economics. More from the Practice Lab →

Keep Reading

16% vocabulary overlap

Clio Was the Best Thing to Happen to Small Firms. You Can Now Build Your Own.

Strip away the interface and a practice management system is a relational database, a set of views, and an API. That used to be worth every dollar. Now the database is a weekend, the connectors reach anything, and the honest question is which parts you should still rent.

Essay · 16 min read

11% vocabulary overlap

Your Firm Fits in One Folder

Everything in this section — the wiki, the agent briefs, the checklists, the eval set — lives in a single directory of plain text. That one architectural fact is what lets you change models on a Tuesday afternoon, including to one running on the machine under your desk.

Essay · 12 min read

11% vocabulary overlap

Build a Firm Wiki Your AI Can Query

The reason AI gives you generic legal work is that it has no idea where it is. A structured Obsidian knowledge base — plain markdown, real schema, provenance on every fact — turns a general assistant into one that already knows your practice.

Workflow · 15 min read

← All Practice Lab articles