HTML Over the Wire
SQLite under a hypermedia app
Lesson 4 of 5
What you'll learn
- Configure the SQLite DSN:
journal_mode(WAL),busy_timeout,foreign_keys— and know why - Understand SQLite's single-writer rule and where "database is locked" comes from
- Pair queries with partials: a list query behind the page, a row query behind the fragment
The previous course settled the driver: modernc.org/sqlite, pure Go, registered as "sqlite", so CGO_ENABLED=0 static builds keep working. What a hypermedia app adds is pressure: every click is a request, and many of them write. That collides with SQLite's core rule — one writer at a time. A second connection that tries to write while the lock is held fails immediately with SQLITE_BUSY: the infamous database is locked.
You don't fix that in code. You fix it in the DSN, once:
db, err := sql.Open("sqlite",
"file:app.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)")
Each pragma earns its place:
journal_mode(WAL)— the write-ahead log lets readers keep reading while a write is in progress (the default rollback journal blocks them). For a hypermedia app that's most of your concurrency problem solved: GETs never queue behind a POST.busy_timeout(5000)— the second writer no longer fails instantly; it retries for up to 5 seconds until the lock frees. Since handler writes take microseconds, in practice writers just take turns.foreign_keys(1)— SQLite ships with foreign key enforcement off for historical reasons. Turn it on or yourREFERENCESclauses are decoration.
Pragmas are per connection — the DSN is the right place
database/sql maintains a connection pool, and each new connection starts with SQLite defaults. _pragma= in the DSN (the modernc.org/sqlite syntax) applies these settings to every pooled connection. A one-off db.Exec("PRAGMA ...") at startup configures only whichever connection it happened to grab.
The query layer then falls into the same two shapes as the templates — a list query behind the full page, a row query behind the fragment. RETURNING (SQLite 3.35+) makes insert-then-render one statement:
func (s *Store) List() ([]Todo, error) {
rows, err := s.db.Query(`SELECT id, title, done FROM todos ORDER BY id`)
// ... defer rows.Close(); scan each row; return the slice
}
func (s *Store) Add(title string) (Todo, error) {
var t Todo
err := s.db.QueryRow(
`INSERT INTO todos (title) VALUES (?) RETURNING id, title, done`,
title,
).Scan(&t.ID, &t.Title, &t.Done)
return t, err
}
List feeds {{range .Todos}}{{template "todo-item" .}}{{end}}; Add feeds one todo-item execution. Query, partial, and swap line up one-to-one — that symmetry is most of the architecture.
The challenge is a single-writer lock simulator: three overlapping writes, run once with no timeout and once with busy_timeout, to see who errors and who waits.
Run it. Three handler writes overlap in time. With busy_timeout=0, the two latecomers fail instantly with SQLITE_BUSY; with busy_timeout=5000 they wait a few ms and succeed. Try shrinking the timeout below a write's wait to see it fail again.
What does _pragma=busy_timeout(5000) in the DSN change?
Next: the honest question — when is this stack the right call, and when should you reach for a React SPA instead?
Saved on this device. Sign in to sync your progress everywhere.