From Source to Service
SQLite persistence
Lesson 3 of 5
What you'll learn
- Use
database/sql— Go's standard, driver-agnostic SQL interface - Choose between the cgo (
mattn) and pure-Go (modernc.org) SQLite drivers - Apply schema changes with a simple versioned migration pattern
Go's standard library ships database/sql: a generic SQL interface with connection pooling built in. It's like using pg/better-sqlite3 through one common API — you write against *sql.DB, and a driver (imported for its side effect) does the dialect work.
For SQLite there are two serious drivers, and the tradeoff matters for lesson 5:
github.com/mattn/go-sqlite3— wraps the canonical C library via cgo. Fastest and most battle-tested, but you need a C toolchain, and cross-compiling becomes painful.modernc.org/sqlite— SQLite translated to pure Go. Slightly slower, butCGO_ENABLED=0builds and effortless cross-compiles work, which is exactly what a single-static-binary deploy wants.
import (
"database/sql"
_ "modernc.org/sqlite" // driver registers itself in init()
)
db, err := sql.Open("sqlite", "file:notes.db")
if err != nil { /* handle */ }
// Placeholders, never string concatenation:
row := db.QueryRow("SELECT id, title FROM notes WHERE id = ?", id)
var n Note
if err := row.Scan(&n.ID, &n.Title); err != nil { /* sql.ErrNoRows? */ }
_, err = db.Exec("INSERT INTO notes (title) VALUES (?)", title)
? placeholders are prepared statements under the hood: the SQL text and the values travel separately, so user input can never rewrite the query. This is the same reason parameterized queries beat template literals in Node — except database/sql gives you no convenient footgun to skip it.
Scan is positional and typed
row.Scan(&n.ID, &n.Title) writes columns into pointers in select-list order. Add a column to the SELECT without adding a Scan target and you get a runtime error, not a compile error. Keep queries and Scans adjacent, and let a test (lesson 4) catch drift.
Migrations without a framework
SQLite stores a schema version for you in the user_version pragma. That plus an ordered slice of SQL is a complete migration system:
var migrations = []string{
`CREATE TABLE notes (id INTEGER PRIMARY KEY, title TEXT NOT NULL)`,
`ALTER TABLE notes ADD COLUMN done INTEGER NOT NULL DEFAULT 0`,
}
func migrate(db *sql.DB) error {
var v int
db.QueryRow("PRAGMA user_version").Scan(&v)
for i := v; i < len(migrations); i++ {
if _, err := db.Exec(migrations[i]); err != nil {
return fmt.Errorf("migration %d: %w", i+1, err)
}
db.Exec(fmt.Sprintf("PRAGMA user_version = %d", i+1))
}
return nil
}
Run migrate on startup, before ListenAndServe. Migrations are append-only: never edit an entry that has shipped — add a new one.
Run it. This models the user_version migration runner: the first boot applies everything, the second applies only what's new, and already-applied migrations never re-run.
Why prefer modernc.org/sqlite over mattn/go-sqlite3 for a service you'll cross-compile?
Next: the store and the handlers exist — now prove they work, with table-driven tests and httptest.
Saved on this device. Sign in to sync your progress everywhere.