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 STRICTsqlite1-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.dbsqlite1-1's own opening example, now genuinely exercised end to end rather than just described.

Chapter Attribution

Capstone elementChapter
No server, one file, cp-as-backupsqlite1-1
sqlite3 CLI used during development/inspectionsqlite1-2
STRICT tables protecting amount from silent type coercionsqlite1-3
No WAL mode needed — single-process CLI toolsqlite1-4 (deliberately not applied — see scope note)
PRAGMA foreign_keys = ON explicitly set, not forgottensqlite1-5
Genuinely local-first, offline-capable designsqlite1-6
Matches the framework's own strongest-fit casesqlite1-7
Parameterized queries, context managers, Python's sqlite3sqlite1-8
No user/permission system needed — single user, single machinesqlite1-9
Honest scope note
This capstone deliberately has no multi-user access — it's the single-application use case 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.
The throughline, closed
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

Exercise 1

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 solution
Exercise 2

Explain 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 solution
Exercise 3

Using 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 solution

Chapter 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