The missing plug
You are building a local inventory tracker or a small microservice that needs to remember data between restarts. Spinning up PostgreSQL feels like overkill. SQLite sits in a single file, requires zero configuration, and ships with the OS. It seems like the perfect fit. You add the import, call sql.Open, and your program crashes with a driver panic. The problem is not your code. It is how Go handles database drivers.
How the standard library actually works
Go does not ship with a built-in database driver. The standard library provides database/sql, which is an interface layer, not an implementation. Think of it like a universal power strip. It has the sockets, but it needs a specific plug to work with your device. You must import a third-party driver that registers itself with the standard library. For SQLite, the community standard is github.com/mattn/go-sqlite3. It wraps the official C library using Go's cgo bridge. When you import it with a blank identifier, it runs its init() function in the background and tells database/sql, "I can handle the sqlite3 dialect."
The blank identifier import is a deliberate Go convention. You never call functions on the driver package directly. The package exists solely to execute its initialization code, which registers the driver name in a global map inside database/sql. If you type the import without the underscore, the compiler rejects the file with imported and not used: github.com/mattn/go-sqlite3. The language enforces explicit dependencies. You either use a package or you acknowledge that you are importing it for its side effects. This pattern appears across the standard library for drivers, codecs, and template functions. The underscore tells the compiler, "I know this package has no exported functions I will call. I am importing it for its initialization side effects."
Minimal setup
Here is the bare minimum to open a file and verify the connection.
package main
import (
"database/sql"
// Blank identifier imports the driver for its init() side effect.
_ "github.com/mattn/go-sqlite3"
)
func main() {
// sql.Open validates the DSN and initializes a connection pool.
// It does not actually connect to the database yet.
db, err := sql.Open("sqlite3", "file:mydb.sqlite3?cache=shared")
if err != nil {
panic(err)
}
// Defer ensures the pool closes and releases the file handle.
defer db.Close()
// Ping forces the first connection and verifies the file exists.
if err := db.Ping(); err != nil {
panic(err)
}
}
sql.Open does not actually connect to the database. It parses your connection string, validates the driver name, and initializes a connection pool. If you want to know whether the file exists and the driver works, you must call db.Ping(). The pool manages multiple connections behind the scenes. SQLite is a single-file database, so concurrent writes can cause locks. The driver handles this by queuing requests, but you need to tune the pool size and SQLite pragmas to avoid deadlocks in production.
What happens under the hood
When your program calls db.Query or db.Exec, database/sql checks its internal pool for an available connection. If one is free, it hands it to your goroutine. If the pool is empty, it creates a new connection up to the maximum limit. SQLite files do not scale with multiple open connections the way network databases do. The underlying C library uses file-level locking. Without careful configuration, two goroutines trying to write at the same time will trigger a database is locked panic.
The file: URI scheme in the DSN tells the driver to use SQLite's URI filename interface. This unlocks query parameters that control database behavior. The cache=shared parameter enables shared cache mode, which allows multiple connections in the same process to share a single in-memory cache. This reduces memory overhead and speeds up repeated reads. The standard library handles connection lifecycle management automatically. You do not need to track individual connections. You interact with the *sql.DB handle, which is safe for concurrent use by multiple goroutines.
Connection pooling in database/sql follows a lazy initialization pattern. The pool starts empty. Connections are created on demand when goroutines request them. Idle connections are kept alive up to SetMaxIdleConns. If the pool exceeds SetMaxOpenConns, new requests block until a connection is released. This design works beautifully for network databases like PostgreSQL, where each connection is a separate TCP socket. SQLite behaves differently. The database lives on disk. Multiple open connections compete for the same file locks. You must align the pool configuration with SQLite's concurrency model instead of copying PostgreSQL defaults.
Production-ready configuration
Real applications need WAL mode for concurrent reads, a busy timeout to handle lock contention, and a capped connection pool. SQLite defaults to a single writer. Without WAL (Write-Ahead Logging), readers block when a writer runs. Setting _journal_mode=WAL and _busy_timeout=5000 in the DSN fixes this. You also need to limit db.SetMaxOpenConns(1) because SQLite files do not scale with multiple open connections the way network databases do.
package main
import (
"database/sql"
// Blank identifier registers the sqlite3 driver with database/sql.
_ "github.com/mattn/go-sqlite3"
)
func main() {
// WAL mode enables concurrent readers. Busy timeout prevents immediate lock failures.
dsn := "file:mydb.sqlite3?_journal_mode=WAL&_busy_timeout=5000&_txlock=immediate"
db, err := sql.Open("sqlite3", dsn)
if err != nil {
panic(err)
}
defer db.Close()
// SQLite files perform best with a single open connection.
// The pool will reuse this connection across all goroutines.
db.SetMaxOpenConns(1)
// Verify the connection and file permissions before proceeding.
if err := db.Ping(); err != nil {
panic(err)
}
}
The _txlock=immediate parameter tells SQLite to acquire write locks immediately when a transaction begins. This prevents the "database is locked" error from occurring mid-transaction. The connection pool will queue incoming requests and execute them sequentially. This matches SQLite's design philosophy. It is a lightweight embedded database, not a distributed system. You trade horizontal scalability for zero operational overhead.
WAL mode changes how SQLite handles writes. Instead of modifying the main database file directly, SQLite appends changes to a separate write-ahead log file. Readers continue accessing the original file while writers append to the log. When a transaction commits, SQLite checkpoints the log into the main file. This eliminates read-write blocking. The tradeoff is a slightly larger disk footprint due to the WAL and shared memory files. For most applications, the performance gain outweighs the extra megabytes.
Common traps and compiler feedback
The blank identifier import is a common tripwire. If you type import "github.com/mattn/go-sqlite3" without the underscore, the compiler rejects the file with imported and not used: github.com/mattn/go-sqlite3. The driver only exists to register itself, so you never call its functions directly. Another trap is assuming sql.Open catches missing files. It does not. The error surfaces on the first query or Ping. If you pass a malformed DSN, you get sql: driver does not support conn.Open.
cgo is another hurdle. The driver requires a C compiler. If your environment lacks gcc or clang, the build fails with a cgo compilation error. Cross-compiling to Windows or ARM also breaks unless you set up a cross-compilation toolchain. The driver compiles SQLite's C source into your binary, which increases binary size by roughly two megabytes. This is acceptable for most applications, but it matters for embedded devices or serverless functions with strict size limits.
Error handling follows Go's standard pattern. You check err != nil immediately after every database/sql call. The standard library returns wrapped errors that preserve the original SQLite error code. You can use errors.Is or errors.As to check for specific database conditions. Do not swallow errors with blank identifiers unless you explicitly want to ignore them. The verbose error checking is intentional. It forces you to acknowledge failure paths instead of hiding them behind silent defaults.
When debugging connection issues, enable SQLite's verbose logging by adding _trace=1 to the DSN. This prints every SQL statement executed by the driver. It reveals prepared statement caching behavior and helps you spot N+1 query patterns. You can also call db.Stats() to inspect pool utilization. The OpenConnections field shows how many connections are currently active. The InUse field shows how many are actively executing queries. If InUse consistently hits your MaxOpenConns limit, your application is bottlenecked on database access.
Choosing your driver
Use github.com/mattn/go-sqlite3 when you need maximum performance and full SQLite feature parity, and you are comfortable with cgo dependencies. Use modernc.org/sqlite when you want a pure-Go implementation that cross-compiles without a C compiler and avoids cgo overhead. Use database/sql directly for all query execution, statement preparation, and transaction management. Use an ORM or query builder only when your schema is highly dynamic and you need to generate SQL programmatically. Use plain database/sql with prepared statements for most production workloads. Prepared statements cache query plans and protect against injection attacks without adding abstraction layers.