Use SQLite in Go With and Without cgo and Pick Between Them
Choose between mattn/go-sqlite3, which needs cgo, and modernc.org/sqlite, which does not: build sizes, cross compiling, 100000 inserts and the cgo stub error.

SQLite is the most deployed database in the world and a good fit for Go programs that want an embedded store: command line tools, desktop apps, small services, test fixtures. The first decision when you add it is which driver, and the choice comes down to one question: do you want cgo?
Tested with Go 1.25.6, modernc.org/sqlite v1.50.0 (SQLite 3.53.0), mattn/go-sqlite3 v1.14.24 (SQLite 3.46.1), macOS on Apple Silicon (arm64)
This guide compares the two drivers that matter on Go 1.25.6, builds the same program with each, and says when to choose which. The setup, pool and concurrency details specific to modernc are in its own article, and the in-memory test use is in SQLite in tests.
The two drivers
mattn/go-sqlite3 (github.com/mattn/go-sqlite3) is the long-standing driver. It embeds the SQLite C amalgamation and calls it through cgo. Building it needs a C compiler on the build machine, and the first build compiles SQLite itself, which took about 20 seconds here. It registers the driver name sqlite3.
modernc.org/sqlite is SQLite translated mechanically from C to Go. It needs no C compiler and no cgo, and it registers the driver name sqlite. The cost is a larger translated codebase and, in the past, a speed gap, which has narrowed.
A third option, ncruces/go-sqlite3, runs SQLite compiled to WebAssembly through a Go runtime and also avoids cgo. I did not run it for this article.
One program, two builds
The program opens an in-memory database, prints the SQLite version, inserts 100000 rows inside one transaction with a prepared statement, counts them and reports the time. The driver choice is isolated in two tiny files behind build tags, so the same main.go builds with either.
//go:build mattn
package main
import _ "github.com/mattn/go-sqlite3"
const driverName = "sqlite3"//go:build modernc
package main
import _ "modernc.org/sqlite"
const driverName = "sqlite"package main
import (
"database/sql"
"fmt"
"os"
"time"
)
func main() {
db, err := sql.Open(driverName, ":memory:")
if err != nil {
fmt.Println("open:", err)
os.Exit(1)
}
defer db.Close()
db.SetMaxOpenConns(1)
var v string
if err := db.QueryRow("select sqlite_version()").Scan(&v); err != nil {
fmt.Println("query:", err)
os.Exit(1)
}
_, _ = db.Exec(`create table t (id integer primary key, n integer, s text)`)
start := time.Now()
tx, _ := db.Begin()
st, _ := tx.Prepare(`insert into t (n, s) values (?, ?)`)
for i := range 100000 {
_, _ = st.Exec(i, "row")
}
_ = tx.Commit()
took := time.Since(start)
var n int
_ = db.QueryRow(`select count(*) from t`).Scan(&n)
fmt.Printf("%s: SQLite %s, %d rows inserted in %dms\n", driverName, v, n, took.Milliseconds()/10*10)
}size() { ls -l "$1" | awk '{printf "%.1f MB", $5/1048576}'; }
echo "# build and run each driver (cgo needs a C compiler; the first mattn build compiles SQLite itself)"
CGO_ENABLED=0 go build -tags modernc -o app_modernc . && printf 'modernc binary: %s\n' "$(size app_modernc)" && ./app_modernc
CGO_ENABLED=1 go build -tags mattn -o app_mattn . && printf 'mattn binary: %s\n' "$(size app_mattn)" && ./app_mattn
echo
echo "# cross compile to Linux arm64 from this Mac with CGO_ENABLED=0"
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -tags modernc -o app_modernc_linux . && echo "modernc: built $(size app_modernc_linux), $(file -b app_modernc_linux | cut -d, -f1-2)"
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -tags mattn -o app_mattn_linux . && echo "mattn: the build succeeds, but without cgo the driver is a stub; running it on this Mac shows what the target would print:"
CGO_ENABLED=0 go build -tags mattn -o app_mattn_nocgo . && ./app_mattn_nocgo 2>&1 | cut -c1-110# build and run each driver (cgo needs a C compiler; the first mattn build compiles SQLite itself)
modernc binary: 9.0 MB
sqlite: SQLite 3.53.0, 100000 rows inserted in 70ms
mattn binary: 6.4 MB
sqlite3: SQLite 3.46.1, 100000 rows inserted in 70ms
# cross compile to Linux arm64 from this Mac with CGO_ENABLED=0
modernc: built 8.7 MB, ELF 64-bit LSB executable, ARM aarch64
mattn: the build succeeds, but without cgo the driver is a stub; running it on this Mac shows what the target would print:
query: Binary was compiled with 'CGO_ENABLED=0', go-sqlite3 requires cgo to work. This is a stubReading the results
Size. The modernc binary is 9.0 MB and the mattn binary 6.4 MB. The translated SQLite is bigger than the compiled C. Neither is large for a server; for a tiny command line tool, 2.6 MB is a difference you may care about.
Version. modernc 1.50.0 bundles SQLite 3.53.0 and mattn 1.14.24 bundles 3.46.1. Each driver release pins an SQLite version, so features and fixes depend on which release of the driver you hold. Check select sqlite_version() in your tests, and read the SQLite release notes if you rely on something recent such as new JSON functions.
Speed. Both drivers inserted 100000 rows in about 70 ms. For a workload dominated by SQL execution, such as bulk loads, the two are close on this machine. Do not generalise from one micro measurement: queries that return many rows cross the cgo boundary per row in mattn, which has a cost, while modernc's pure Go runs everything in the Go runtime. If speed matters, run your own queries on both. The method for honest numbers is in the benchmark guide.
Cross compiling. With CGO_ENABLED=0, modernc built a linux/arm64 executable on a Mac in a single command, and the file type reports an ARM aarch64 ELF binary. That is the practical advantage: no cross compiler toolchain, a build that works in a bare golang container, and a final image that can be scratch or distroless. mattn needs a C compiler for every target, which in practice means zig cc, a Docker build per platform, or a CC for each architecture.
The cgo stub
The last block shows the failure that catches people. Building the mattn program with CGO_ENABLED=0 succeeds, because the package includes a stub for that case. At run time every operation fails with:
Binary was compiled with 'CGO_ENABLED=0', go-sqlite3 requires cgo to work. This is a stubThe same error appears when a build environment silently disables cgo: a minimal Alpine image with no gcc, a CI step that sets CGO_ENABLED=0 for all Go builds, or GOOS/GOARCH set to a target for which the toolchain turns cgo off by default. The program compiles and starts and then fails on its first query, which makes it an easy bug to miss in a pipeline that does not run the binary. If you use mattn, make the build fail loudly: set CGO_ENABLED=1 explicitly in the Dockerfile and run a smoke query in CI.
Choosing
| Question | modernc | mattn |
|---|---|---|
| Needs a C compiler to build | No | Yes |
| Cross compile with one command | Yes | No |
Works in scratch or distroless images | Yes | Only with a static build |
| Reference C implementation | No, a translation | Yes |
| Binary size in this test | 9.0 MB | 6.4 MB |
| Time for 100000 inserts | About 70 ms | About 70 ms |
| Driver name | sqlite | sqlite3 |
| Loadable C extensions | Not supported | Supported |
| Build time cold | Quick | About 20 s for SQLite |
Choose modernc when you ship to several platforms, build in minimal containers, or value a plain go build. Choose mattn when you need a C extension (full text search tokenizers, vector extensions), when you have an existing deployment on it, or when you want the reference implementation for behavioural certainty. Both implement database/sql, so switching later is a driver import and a name; keep the choice in one file, as the lab does, to keep the option open.
Docker images for each
The practical differences show up in the Dockerfile. With modernc the build stage needs only the Go toolchain and the final stage needs nothing:
FROM golang:1.25 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /app .
FROM gcr.io/distroless/static-debian12
COPY --from=build /app /app
ENTRYPOINT ["/app"]With mattn the build stage needs a C compiler, which the official golang image includes, and the final stage needs a C runtime that matches the one the binary was linked against, unless you link statically:
FROM golang:1.25 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=1 go build -trimpath -ldflags '-linkmode external -extldflags "-static"' -tags sqlite_omit_load_extension -o /app .
FROM gcr.io/distroless/static-debian12
COPY --from=build /app /app
ENTRYPOINT ["/app"]Both Dockerfiles are shown as illustration and were not built for this article; the first is the shape that the cross-compile test above supports. A static cgo link against glibc prints warnings for some functions, and building on Alpine with musl is the other common route. If a build works on your laptop and the container crashes at start with a loader error, you have linked dynamically against a library the final image lacks.
Moving from one driver to the other
The code that uses database/sql does not change, but five details do.
- The driver name.
sqliteagainstsqlite3. Keep it in one constant. - DSN options. Each driver has its own parameter names for pragmas, busy timeouts and time handling. modernc takes
_pragma=name(value)pairs, and mattn takes parameters like_busy_timeoutand_foreign_keys. A DSN copied from one to the other is silently ignored in part, which means a missingforeign_keyspragma and no error. - Time values. The drivers differ in how they format and parse
time.Timein text columns, so rows written by one may need care when read by the other. Test a round trip of a value with a non-UTC zone before you migrate data. - Error types. Errors are driver-specific types. Code that checks
sqlite3.ErrConstraintUniqueor a result code needs the other driver's equivalent. Isolate this in one function that classifies errors. - Bundled SQLite version. A query that uses a newer feature works on one and fails on the other. Check
sqlite_version()at start-up.
The database file itself is portable: both read and write the standard SQLite file format, so you can open a file created by one with the other.
Maintenance and licensing
mattn/go-sqlite3 is MIT licensed and has a long history; modernc.org/sqlite is BSD-3-Clause licensed, as is SQLite's translation toolchain in modernc.org/cc and modernc.org/ccgo. SQLite itself is public domain. Releases of both follow SQLite's, with a delay. For a library you publish, the driver choice belongs to the application, not to you: depend on database/sql only and let the main package import the driver, so that your users are not forced into your cgo decision.
Running both in CI
If you support both drivers, test both. A build matrix with two jobs, one with -tags modernc and CGO_ENABLED=0 and one with -tags mattn and CGO_ENABLED=1, runs the same test suite twice, and surfaces driver differences before users do. The lab's approach, with the driver behind a build tag in two small files, is the arrangement that makes that matrix trivial. Add a smoke test that opens an in-memory database and runs select sqlite_version(), which catches the cgo stub at once, as the last block of the lab shows.
The pieces that are the same
SQLite's behaviour belongs to SQLite, not to the driver, and these hold for both.
One writer at a time. SQLite allows many readers and one writer. Concurrent writers get SQLITE_BUSY ("database is locked") unless you set a busy timeout. The fix is covered in the modernc article, with a measurement.
Pragmas are per connection. PRAGMA foreign_keys = ON applies to the connection that ran it, not the database. A pooled database/sql handle opens several connections, so set pragmas through the DSN, where the driver supports it, or constrain the pool.
Types are flexible. A column's declared type is advisory, as shown in the in-memory testing article, and STRICT tables enforce types.
In-memory databases are per connection unless you use a shared cache URI, so SetMaxOpenConns(1) or a named shared database is needed in tests.
WAL mode gives concurrent readers during a write and is the usual setting for a service.
Running SQLite in production
A few habits turn an embedded database into a dependable one, whichever driver you use.
Put the file on a local disk you can back up. A network file system breaks the locking SQLite depends on. On a container platform, mount a persistent volume and do not store the database in the container's writable layer.
Back up with the database's own tools. Copying a live database file can capture a half-written state. The statement VACUUM INTO '/backups/app.db' writes a consistent, compacted copy while the database is in use, and the C library's online backup API does the same incrementally. In WAL mode the main file alone is not the whole database: the -wal and -shm files belong with it, which is another reason not to copy files by hand. Tools that stream the WAL to object storage exist for continuous replication, and are worth evaluating if the data matters.
Use WAL mode and a busy timeout. Together they remove most database is locked errors, as measured in the modernc article.
Shape the pool to SQLite's single writer. The common arrangement is one connection for writes, with SetMaxOpenConns(1), and a separate pool of several connections for reads. Writers queue in the pool instead of failing with busy errors. Transactions that start as reads and later write can still fail with a busy error when another writer commits first; begin write transactions with BEGIN IMMEDIATE so the lock is taken at the start.
Run PRAGMA integrity_check and PRAGMA optimize occasionally, the first after a crash or in a nightly job, the second before closing a long-lived connection, so the query planner has fresh statistics.
Keep the schema migrations in code. SQLite's ALTER TABLE is narrower than PostgreSQL's, so some changes need the create-copy-rename dance. A migration tool such as goose runs the same SQL files against SQLite.
Testing
The two drivers make different promises for tests. modernc lets a test binary run anywhere Go runs, which is a quiet advantage in CI images and on contributors' machines without a compiler. If your production code uses mattn for an extension, test with mattn too, and keep a small suite with CGO_ENABLED=1. For fast tests with an in-memory database, see testing against SQLite in memory, which also lists the ways SQLite differs from PostgreSQL. Treat SQLite as a stand-in only for SQL that is portable.
Where SQLite is the wrong choice
SQLite is not a network database. A fleet of application servers cannot share one file over NFS safely, and a service that needs many concurrent writers or replication to read replicas should use PostgreSQL or MySQL. It shines when the database belongs to one process: a CLI's state, an edge node's cache, a single-instance service, a desktop application. The SQLite project's own page on appropriate uses lists both sides, and the driver documentation for modernc and mattn lists the supported DSN options. For the other databases in this series, see PostgreSQL and MySQL.


