Capstone — Building a Local-First CLI Tool With SQLite
SQLite
Chapter 10 · Capstone: Building a Local-First CLI Tool With SQLite
Nine chapters covered SQLite's own architecture, type system, concurrency, transactions, real-world deployment, decision framework, application code, and limitations. This capstone combines them into one real, working personal expense tracker — a genuine demonstration of the "no server, just a file" workflow, not an abstract description of it.
The Scenario
A personal expense tracker a single person runs on their own machine to log and review their own spending — exactly the use case sqlite1-7's own decision framework identifies as SQLite's strongest, least-debatable fit: a single embedded application, no server needed at all.
The Schema
CREATE TABLE categories (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE
) STRICT;
CREATE TABLE expenses (
id INTEGER PRIMARY KEY,
description TEXT NOT NULL,
amount REAL NOT NULL,
category_id INTEGER NOT NULL REFERENCES categories(id),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
) STRICT;
Both tables use STRICT — sqlite1-3's own opt-in fix, applied here as a real design decision rather than an abstract example: amount accidentally storing a text value instead of a number would silently corrupt every later SUM()-based report, which is exactly the class of bug STRICT exists to prevent at insert time.
The Application Code
import sqlite3
def get_connection():
conn = sqlite3.connect("expenses.db")
conn.execute("PRAGMA foreign_keys = ON") # sqlite1-5's own gotcha, deliberately not forgotten
return conn
def add_category(name):
with get_connection() as conn:
conn.execute("INSERT INTO categories (name) VALUES (?)", (name,))
def add_expense(description, amount, category_name):
with get_connection() as conn:
row = conn.execute(
"SELECT id FROM categories WHERE name = ?", (category_name,)
).fetchone()
if row is None:
raise ValueError(f"Unknown category: {category_name}")
conn.execute(
"INSERT INTO expenses (description, amount, category_id) VALUES (?, ?, ?)",
(description, amount, row[0]),
)
def list_expenses():
with get_connection() as conn:
return conn.execute("""
SELECT expenses.description, expenses.amount, categories.name, expenses.created_at
FROM expenses JOIN categories ON expenses.category_id = categories.id
ORDER BY expenses.created_at DESC
""").fetchall()
def summary_by_category():
with get_connection() as conn:
return conn.execute("""
SELECT categories.name, SUM(expenses.amount) AS total
FROM expenses JOIN categories ON expenses.category_id = categories.id
GROUP BY categories.name
ORDER BY total DESC
""").fetchall()
Every query is parameterized, per sqlite1-8's own security material — no string concatenation of user-supplied values anywhere. Every connection turns on PRAGMA foreign_keys = ON explicitly, closing the exact gap sqlite1-5's own warn-box named. Connections are opened and closed via context managers, per sqlite1-8's own resource-management guidance.
Why This Is Genuinely Local-First
This tool runs entirely on the user's own machine and works completely offline — sqlite1-6's own local-first strength, not a theoretical one. The entire dataset lives in one file, expenses.db, which the user could back up with nothing more than cp expenses.db backup.db — sqlite1-1's own opening example, now genuinely exercised end to end rather than just described.
Chapter Attribution
| Capstone element | Chapter |
|---|---|
| No server, one file, cp-as-backup | sqlite1-1 |
| sqlite3 CLI used during development/inspection | sqlite1-2 |
| STRICT tables protecting amount from silent type coercion | sqlite1-3 |
| No WAL mode needed — single-process CLI tool | sqlite1-4 (deliberately not applied — see scope note) |
| PRAGMA foreign_keys = ON explicitly set, not forgotten | sqlite1-5 |
| Genuinely local-first, offline-capable design | sqlite1-6 |
| Matches the framework's own strongest-fit case | sqlite1-7 |
| Parameterized queries, context managers, Python's sqlite3 | sqlite1-8 |
| No user/permission system needed — single user, single machine | sqlite1-9 |
sqlite1-7's framework identifies as SQLite's strongest fit, not a limitation being glossed over. It has no server deployment — the tool is embedded and local by design, consistent with this entire course's own throughline. It uses no ORM layer — raw, parameterized SQL is used deliberately throughout so every chapter's own material stays visible in the actual code. And it deliberately does not enable WAL mode from sqlite1-4 — a single-process CLI tool has no meaningful concurrent-reader/writer scenario for WAL to improve, an honest acknowledgment that not every chapter's own feature belongs in every real project, the same pattern postgres1-12's own capstone set.
sqlite1-1 opened this course by claiming SQLite exists to let a single application embed a real, ACID-compliant database with zero server infrastructure. This capstone is the proof: a real, working tool, one file, no server, full transactional integrity, built entirely on the material this course actually covered.
Hands-On Exercises
Explain why the expenses table uses STRICT specifically for its amount column, tying your answer to sqlite1-3's own type-affinity material and a concrete scenario of what could go wrong without it.
📄 View solutionExplain why get_connection() explicitly runs PRAGMA foreign_keys = ON on every connection, tying your answer to sqlite1-5's own warn-box, and describe what could go wrong in this specific application if that line were removed.
📄 View solutionUsing this chapter's own scope note, explain why WAL mode (sqlite1-4) was deliberately NOT applied to this capstone, and explain why this is presented as an honest design choice rather than an oversight.
📄 View solutionChapter 10 Quick Reference — Course Complete
- A real, working local-first CLI expense tracker — one file, no server, full ACID integrity
- STRICT tables (sqlite1-3) protect amount from silent type-affinity coercion
- PRAGMA foreign_keys = ON explicitly set on every connection (sqlite1-5) — the gotcha not forgotten
- Parameterized queries and context managers throughout (sqlite1-8)
- WAL mode (sqlite1-4) deliberately NOT used — a single-process CLI tool has no meaningful concurrency need for it
- No multi-user access, no server deployment, no ORM — honest, deliberate scope, not gaps
- This closes the full 10-chapter SQLite course