Applications and operations

Build a small notes database

Build and verify a complete SQLite notes database with strict tables, relations, an index, and a restored backup.

Let’s finish the course by building one database from an empty directory. Every step is something you’ve done in an earlier lesson; the point is doing them in order, the way a real project would.

Open notes.db, enable foreign keys, and create the schema:

sqlite3 notes.db
PRAGMA foreign_keys = ON;

CREATE TABLE notes (
  id INTEGER PRIMARY KEY,
  title TEXT NOT NULL,
  body TEXT,
  archived INTEGER NOT NULL DEFAULT 0
    CHECK (archived IN (0, 1)),
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
) STRICT;

CREATE TABLE tags (
  id INTEGER PRIMARY KEY,
  name TEXT NOT NULL UNIQUE
) STRICT;

CREATE TABLE note_tags (
  note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
  tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
  PRIMARY KEY (note_id, tag_id)
) STRICT;

Notice the schema carries its own rules: STRICT types, a CHECK that keeps archived boolean-shaped, a UNIQUE tag name, and cascading deletes so removing a note cleans up its links. None of this depends on application code behaving.

Insert related data as one transaction:

BEGIN IMMEDIATE;
INSERT INTO notes (title, body)
VALUES ('Plan the week', 'Pick the three important tasks');
INSERT INTO tags (name) VALUES ('planning');
INSERT INTO note_tags (note_id, tag_id)
SELECT notes.id, tags.id
FROM notes, tags
WHERE notes.title = 'Plan the week'
  AND tags.name = 'planning';
COMMIT;

The three inserts describe one fact — a tagged note — so they belong in one transaction. If the third insert failed, a ROLLBACK would leave no half-linked data behind. BEGIN IMMEDIATE claims the write lock up front, so a competing writer surfaces now rather than mid-transaction.

Inspect a real lookup before adding an index:

EXPLAIN QUERY PLAN
SELECT id, title FROM notes WHERE title = 'Plan the week';

You should see SCAN notes — a full table walk. Now add the index and run the plan again:

CREATE INDEX notes_title_idx ON notes(title);

The second plan should report a search using notes_title_idx instead of a table scan. That before-and-after check is the habit that keeps you from collecting indexes that do nothing.

Create a live backup:

.backup notes-backup.db

Leave with .quit, then open the restored copy:

sqlite3 notes-backup.db

This is the step most people skip: actually opening the backup. Finish with these checks:

PRAGMA foreign_keys = ON;
PRAGMA integrity_check;
PRAGMA foreign_key_check;
SELECT notes.title, tags.name
FROM notes
JOIN note_tags ON note_tags.note_id = notes.id
JOIN tags ON tags.id = note_tags.tag_id;

You are done when integrity_check returns ok, foreign_key_check returns no rows, and the final query returns Plan the week|planning.

If any check fails, the backup is the suspect, not the original — repeat the .backup while the source database is quiet and verify again. And if the join returns nothing, re-run it against notes.db: an empty result there means the transaction never committed, an empty result only in the copy means you backed up before the commit.

That’s the full loop: schema with rules, transactional writes, evidence-based indexing, and a backup you proved restores. Every database you ship deserves the same treatment.

Lesson completed

Take this course offline

Get every free book, course edition, and software download.

Get the download library →