The Trade-offs of Pure Go SQLite: Taming Context Cancellation
A technical look at our decision to drop CGO and use a pure Go SQLite driver, and the specific query context cancellation patterns required to keep the system robust.
When building a self-hosted platform like Mezite, deployment simplicity is a fundamental security requirement. For our control plane to be truly frictionless, we distribute it as a single, statically-linked binary. We do not want administrators debugging runtime library mismatches or fighting with container orchestration just to get an SSH proxy running.
For state storage, we support PostgreSQL for managed and high-availability deployments. But for the vast majority of our self-hosted users, we use SQLite. It requires zero setup and lives entirely within the local filesystem.
However, SQLite is written in C. Using it in Go traditionally requires CGO, which fundamentally breaks our cross-compilation pipeline and complicates our “single static binary” promise.
Here is why we moved to a pure Go SQLite driver and how we manage the specific context cancellation quirks that come with it.
The CGO Tax
The standard way to use SQLite in Go is via github.com/mattn/go-sqlite3. It is battle-tested, incredibly fast, and wraps the native SQLite C library.
But it requires CGO.
When you enable CGO, you lose the ability to easily cross-compile your binary for different architectures (e.g., building an ARM64 release on an AMD64 CI runner). You suddenly need a C cross-compiler toolchain installed in your build environment for every target architecture. Furthermore, the resulting binary is dynamically linked against the system’s C library (libc or musl) by default, introducing runtime dependencies.
We refuse to accept this tax on our build pipeline and our users’ deployments.
The Pure Go Alternative
To maintain our CGO-free architecture, we use modernc.org/sqlite. This driver is an extraordinary piece of engineering: it is the original SQLite C source code, mechanically translated into pure Go.
It implements the standard database/sql/driver interface, meaning it drops directly into our existing database abstraction layer alongside our PostgreSQL driver.
import (
_ "modernc.org/sqlite" // Pure Go SQLite driver
)
func openDB(dsn string) (*sql.DB, error) {
return sql.Open("sqlite", dsn)
} By using modernc.org/sqlite, we retain the ability to trivially cross-compile Mezite using standard GOOS and GOARCH environment variables. The resulting binary is completely statically linked and runs on any supported Linux kernel without requiring a specific libc version.
The Context Cancellation Quirk
While the pure Go driver solves our deployment constraints, it introduces a subtle but significant behavioral difference, specifically around query cancellation.
In modern Go applications, we pass a context.Context down the call stack to manage timeouts and cancellation. When an HTTP request drops or a user cancels an operation, the context cancels, and the database driver should abort any running queries and return a standard context.Canceled error.
With standard PostgreSQL drivers (like pgx), this works exactly as expected.
With the pure Go SQLite driver, the mechanical translation of the C code carries over native SQLite error codes. When the driver detects that the Go context is canceled during a query execution, it interrupts the underlying SQLite engine.
SQLite engine returns the specific error code SQLITE_INTERRUPT (error code 9). The driver bubbles this up as the string "interrupted (9)" rather than the Go standard context.Canceled error.
If we simply execute a query and return the error directly, our application logic fails to recognize the cancellation. This leads to flaky tests, misleading error logs (reporting internal database errors instead of expected cancellations), and broken retry logic.
// If ctx is canceled, this returns an error containing "interrupted (9)"
// rather than context.Canceled.
rows, err := db.QueryContext(ctx, "SELECT * FROM audit_events")
if err != nil {
// This fails to catch the cancellation!
if errors.Is(err, context.Canceled) {
return nil
}
return err
} Taming the Interruption
To handle this gracefully across our data access layer, we enforce a strict pattern for database queries.
Whenever a query or transaction returns an error, we explicitly check the state of the provided context.Context. If the context has a non-nil Err(), it means the context was canceled or timed out. In this scenario, we discard the driver-specific error and return ctx.Err() instead.
This explicitly masks the "interrupted (9)" SQLite error (or any other driver-specific cancellation quirk) behind the standard context.Canceled or context.DeadlineExceeded.
// Execute a query with robust cancellation handling
rows, err := db.QueryContext(ctx, "SELECT * FROM access_requests")
if err != nil {
// ALWAYS check context state first to normalize driver quirks
if ctx.Err() != nil {
return ctx.Err() // Returns context.Canceled
}
// Return the actual database error if the context is still valid
return err
} By applying this pattern consistently across our repository, we ensure that our application logic remains entirely agnostic to the underlying database driver. Whether the backend is PostgreSQL returning standard errors, or SQLite returning "interrupted (9)", the caller always receives a clean, standard Go error.
Conclusion
Engineering is always about trade-offs. By choosing a pure Go SQLite driver, we sacrifice a small amount of raw execution speed compared to the CGO wrapper. In return, we gain an impenetrable, CGO-free build pipeline and guarantee a seamless deployment experience for our users.
The quirks of mechanically translated C code require us to be disciplined in our error handling, but taming the "interrupted (9)" error is a minor cost for the massive operational win of a single, statically-linked binary.
Mezite Team
Engineering