ADR-0021: Adopt pressly/goose for SQLite Schema Migrations
Context and Problem Statement
Claude Ops uses a hand-rolled migration framework in internal/db/db.go that defines migrations as inline Go functions (migrate001 through migrate007). While functional, this approach lacks rollback support, embeds SQL schema definitions inside Go code (making them harder to review and maintain), and requires writing boilerplate for every new migration. As the schema grows, a dedicated migration library would provide better developer ergonomics, down-migration support, and standard tooling.
Decision Drivers
- Must support SQLite via
modernc.org/sqlite(pure-Go, no CGo) - Simple and lightweight -- not over-engineered for a single-database project
- Well-maintained with active commits and responsive maintainers
- Supports SQL file-based migrations (separate
.sqlfiles, not inline Go code) - Supports both up and down migrations for safe rollbacks
- Embeddable in a Go binary (migrations compiled in via
//go:embed) - Compatible with running migrations on application startup (current
db.Open()pattern)
Considered Options
- Option 1: pressly/goose v3 -- Go migration library and CLI with SQL and Go migration support
- Option 2: golang-migrate/migrate v4 -- Popular file-based migration library and CLI
- Option 3: amacneil/dbmate -- Lightweight, language-agnostic CLI migration tool
- Option 4: rubenv/sql-migrate -- Embeddable Go migration library
- Option 5: Keep the current hand-rolled system
Decision Outcome
Chosen option: "pressly/goose v3", because it provides the best balance of simplicity, active maintenance, native Go embedding via its Provider API, and full compatibility with modernc.org/sqlite through standard database/sql interfaces. It supports both SQL file-based and Go function migrations, has first-class //go:embed support, and its goose.NewProvider() API is designed for programmatic use on startup -- matching our existing db.Open() pattern exactly.
Consequences
- Good, because migrations move from inline Go code to standalone
.sqlfiles that are easier to review, diff, and reason about - Good, because down migrations become possible, enabling safe rollbacks during development
- Good, because
//go:embedsupport compiles migrations into the binary with zero runtime file dependencies - Good, because the Provider API allows running migrations programmatically on startup without a CLI
- Good, because goose uses standard
database/sqlinterfaces, somodernc.org/sqliteworks without any adapter code - Good, because goose is actively maintained (v3.26.0, 7.3k+ stars, regular releases)
- Bad, because adopting goose adds a new dependency to the project
- Neutral, because existing migrations (001-007) need to be converted to
.sqlfiles, but since no other users depend on this database, we can start fresh with goose
Confirmation
Compliance will be confirmed by:
- All existing tests in
internal/db/pass after the migration - A fresh database created with goose produces the same schema as the current system
- The
db.Open()function continues to run migrations automatically on startup - The goose CLI can independently verify migration status (
goose status)
Pros and Cons of the Options
pressly/goose v3
pressly/goose is a database migration tool for Go that supports both SQL file-based and Go function-based migrations. It works as both a CLI and an embeddable library. Version 3.26.0 was released in late 2025, and the project has 7.3k+ GitHub stars.
- Good, because it has a modern Provider API (
goose.NewProvider()) designed for programmatic use - Good, because it supports
//go:embednatively viafs.FSparameter in the Provider API - Good, because it uses standard
database/sqlinterfaces -- works with any SQLite driver includingmodernc.org/sqlite - Good, because it supports both SQL and Go migrations, providing flexibility for complex data migrations
- Good, because it has sequential and timestamped versioning options
- Good, because it supports transactional migrations (each migration runs in a transaction)
- Good, because it is actively maintained with regular releases and 7.3k+ stars
- Good, because the CLI is useful for development (creating new migrations, checking status)
- Bad, because it adds a dependency (~3k lines of library code)
- Neutral, because switching from
schema_migrationsto goose'sgoose_db_versiontable is trivial since the database can be rebuilt from scratch
golang-migrate/migrate v4
golang-migrate/migrate is the most popular Go migration library with 18k+ GitHub stars. It supports file-based SQL migrations and has drivers for many databases. Version 4.19.1 was released in November 2025.
- Good, because it is the most popular Go migration library (18k+ stars)
- Good, because it has a dedicated
sqlitedriver that usesmodernc.org/sqlite - Good, because it supports
//go:embedvia theiofssource package - Good, because it supports up and down migrations
- Good, because it has a large ecosystem and community
- Bad, because its API is more complex -- requires separate source and database driver instances wired together
- Bad, because the driver model is heavier than needed for a single-database project
- Bad, because it does not support Go function-based migrations (SQL only)
- Bad, because creating a new migration requires manually creating paired up/down files with sequential numbering
- Neutral, because its popularity means more community resources but also a broader surface area
amacneil/dbmate
amacneil/dbmate is a lightweight, language-agnostic database migration tool primarily designed as a CLI. It supports SQLite, PostgreSQL, MySQL, and ClickHouse. It has 6.7k GitHub stars and version 2.29.3 was released in January 2025.
- Good, because it is simple and lightweight with minimal configuration
- Good, because it supports SQLite and up/down SQL migrations
- Good, because it can be embedded as a Go library
- Good, because it uses timestamp-based versioning to avoid conflicts
- Bad, because it is primarily designed as a CLI tool -- library usage is secondary
- Bad, because the Go library API is less documented and less ergonomic than goose or golang-migrate
- Bad, because it uses URL-based database configuration (
sqlite:///path/to/db) rather than accepting a*sql.DBdirectly - Bad, because its SQLite driver uses CGo-based
mattn/go-sqlite3by default --modernc.org/sqlitecompatibility is unclear
rubenv/sql-migrate
rubenv/sql-migrate is an embeddable SQL migration library for Go. It has 3.3k GitHub stars and was last updated in December 2024.
- Good, because it is designed to be embedded in Go applications
- Good, because it supports up and down SQL migrations
- Good, because it has a simple API that works with
*sql.DB - Bad, because it has fewer stars and less community activity (3.3k stars)
- Bad, because it depends on
rubenv/sql-migrate's custom dialect system which may not align withmodernc.org/sqlite - Bad, because it has not had a release since late 2024 -- maintenance may be slowing
- Bad, because it uses
go-gorpinternally for SQL execution, adding an indirect dependency - Bad, because its embedding approach predates
//go:embedand relies onhttp.FileSystemadapters
Keep the current hand-rolled system
Continue using the custom migration framework in internal/db/db.go with inline Go function migrations.
- Good, because it has zero additional dependencies
- Good, because it is already working and well-understood
- Good, because it runs on startup exactly as needed
- Bad, because it lacks down/rollback migration support
- Bad, because migrations are inline Go code, making SQL schema changes harder to review
- Bad, because every new migration requires boilerplate (new function, add to slice, match version number)
- Bad, because there is no CLI tooling for creating new migrations or checking status
- Bad, because it is a custom system that new contributors must learn
Architecture Diagram
More Information
- pressly/goose GitHub repository
- goose Provider API documentation
- goose embedded migrations blog post
- modernc.org/sqlite -- pure-Go SQLite driver
- Related: ADR-0008 (Go + HTMX Web Dashboard) established the Go + SQLite stack