The SQLite driver GoFastr ships
GoFastr apps talk to SQLite through
modernc.org/sqlite, which is the
SQLite C source translated to Go. It is real SQLite with no cgo, so
CGO_ENABLED=0 go build works and the binary has no C toolchain
dependency.
You get it by importing gofastr/sqlite/stdlib for its side effect. That
package registers modernc under the conventional sqlite3 name and
rewrites every DSN opened through it.
import ( "database/sql" _ "github.com/DonaldMurillo/gofastr/sqlite/stdlib" ) db, err := sql.Open("sqlite3", "app.db")
gofastr init writes that import into the app it scaffolds, so a
generated app already has it.
What the DSN rewrite adds
Four parameters go onto every DSN. Set any of them yourself and your value wins.
Foreign keys are on
_pragma=foreign_keys(1).
SQLite ignores FOREIGN KEY clauses unless a connection turns them on,
and every SQLite driver leaves them off. AutoMigrate writes a
FOREIGN KEY clause for each belongs_to relation, except where the
relation's column is the entity's Scope.OwnerField: that column holds
the session identity, which lives in the auth battery's table rather than
in the related entity, so a key on it is one the framework would violate
on every create. Without this parameter an app read as though the
remaining relations were enforced while nothing enforced them.
The same entity declarations on Postgres did enforce them, which meant
one schema had two different guarantees depending on the database.
An upgrade note. Releases before the foreign-key default also emitted the
owner-column clause itself, and a database created by them still carries
it: every create on that table fails, because the column holds the session
identity, which never has a matching row in the table the key names.
SQLite has no DROP CONSTRAINT, so gofastr migrate repair rebuilds the
affected tables instead:
gofastr migrate repair --from=gofastr.yml --db-url=file:app.db # report gofastr migrate repair --from=gofastr.yml --db-url=file:app.db --apply # rebuild
The rebuild works from each table's own stored DDL, so rows, column defaults, indices, and other constraints all survive. Boot logs a warning naming each affected table until it is repaired.
_pragma=foreign_keys(0) exists for the other legacy problem: a database
that accumulated rows pointing at nothing while nothing enforced the keys.
Those rows surface as errors on the next write that touches them. Read the
rows, fix or delete them, and treat the pragma as a temporary measure for
that cleanup, not a fix: it disables enforcement for the whole database,
including the relations that are doing real work.
Timestamps use SQLite's own text layout
_time_format=sqlite.
modernc's default for time.Time binds is Go's String() output, which
looks like 2026-07-20 23:59:59.123456789 +0000 UTC. Nothing in GoFastr
parses that. battery/auth, framework/outbox and the hand written
timestamp scans all read either RFC3339 or the layout SQLite writes, so
a timestamp stored in the default format read back as the zero time and
every session looked expired.
This setting also matches what mattn/go-sqlite3 wrote, so databases created before the driver change stay readable with no migration.
Writers wait instead of failing
_pragma=busy_timeout(5000).
SQLite allows one writer at a time. modernc defaults the timeout to 0,
so a second writer gets SQLITE_BUSY immediately rather than waiting.
Any app serving concurrent requests depends on the wait. mattn defaulted
to 5000ms and this restores it.
Write transactions take the lock when they begin
_txlock=immediate.
The busy timeout covers waiting for a lock, not every way a write can
lose to another writer. SQLite's default BEGIN is DEFERRED: a
transaction that reads before it writes holds a snapshot from that read,
and if another connection commits before the first write, the write
cannot move past that commit. SQLite returns SQLITE_BUSY (extended code
517, SQLITE_BUSY_SNAPSHOT) at once and the timeout never runs. The CRUD
delete reads the row for the audit snapshot before it deletes, so with a
job queue writing in the background a plain DELETE failed with a 500.
BEGIN IMMEDIATE takes the write lock as the transaction starts, where
the busy timeout applies, so concurrent writers queue instead of failing.
A read-only transaction (sql.TxOptions{ReadOnly: true}) still begins
deferred and takes no write lock. _txlock=deferred restores SQLite's
default.
In-memory databases need one connection
sql.Open("sqlite3", ":memory:") gives each pooled connection its own
private database. A test that writes on one connection and reads on
another finds an empty schema, and the failure looks like a missing
table rather than a pooling problem.
db, _ := sql.Open("sqlite3", ":memory:") db.SetMaxOpenConns(1)
Use a file under t.TempDir() when a test needs more than one
connection.
There is one SQLite here
This repository used to carry a second, from scratch SQLite engine that some test suites ran against as a cross check. It was removed. modernc is real SQLite rather than a second reading of the SQL spec, so whenever the two disagreed the answer was always that the local engine was wrong, and its own bugs cost more to find and fix than the cross check ever returned. The suites that used it now run against the driver applications ship.
Common mistakes
Opening :memory: without capping the pool. Each connection gets its
own database, so the writer and the reader see different schemas. The
error says the table does not exist, which sends you looking at your
migration instead of your pool. Call db.SetMaxOpenConns(1), or use a
file under t.TempDir().
Importing the package for its name instead of its side effect. The
import is blank on purpose. import "github.com/DonaldMurillo/gofastr/sqlite/stdlib"
without the _ fails to compile because nothing references the package,
and dropping the import entirely gives you sql: unknown driver "sqlite3"
at sql.Open.
Assuming a dangling foreign key still inserts. It did before this
release and it does not now. An app upgraded onto the new default sees
errors on writes touching rows that already point at nothing, which is
existing corruption becoming visible rather than a new failure. Read the
rows, fix or delete them, then remove any _pragma=foreign_keys(0) you
added while cleaning up.
Reaching for the pragma when an upgraded database rejects every create.
A pre-foreign-keys release may have left a key on an entity's owner
column, and the resulting error names the constraint, not the cause. Run
gofastr migrate repair --from=<spec.yml> --apply to rebuild the table
without it. Disabling foreign keys would only hide the problem, and it
turns off the keys that belong there too.
Deleting a parent before its children. By default, undeclared foreign keys
use NO ACTION, requiring children to be deleted first. To cascade child deletions
automatically, configure .OnDeleteCascade() (or .OnDeleteAction(entity.OnDeleteCascade))
on the relation. ManyToMany pivot tables always cascade automatically. (Note: .Cascade()
enables nested cascade writes, not delete cascading.)
The delete action is written when the table is created or rebuilt. Adding or changing
.OnDeleteAction(...) on an existing table emits no schema diff or warning. Rebuild
the table with a versioned migration to change the action on an existing database.
Removing _time_format=sqlite from a DSN you build yourself. The
readers in battery/auth and framework/outbox accept either RFC3339 or
SQLite's space-separated layout, so a hand-written timestamp in either
form reads back fine. What they cannot read is modernc's own default,
Go's time.Time String() output (2026-07-20 23:59:59.123456789 +0000
UTC); a value stored that way parses as the zero time, so a session
looks expired and a scheduled job looks overdue. That is the whole reason
the parameter is added. If you assemble a DSN by hand rather than letting
sqlite/stdlib rewrite it, carry the parameter across.